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

Introducing PartialOrder #288

Open
wants to merge 33 commits into
base: main
Choose a base branch
from
Open

Introducing PartialOrder #288

wants to merge 33 commits into from

Conversation

beverlylytle
Copy link
Member

@beverlylytle beverlylytle commented Apr 1, 2021

This pull request is to introduce the new second-level module compas_fab.datastructures and its constituents PartialOrder and Action. These come together to form a partially ordered plan. Closes #202

What type of change is this?

  • Bug fix in a backwards-compatible manner.
  • New feature in a backwards-compatible manner.
  • Breaking change: bug fix or new feature that involve incompatible API changes.
  • Other (e.g. doc update, configuration, etc)

Checklist

Put an x in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code.

  • I added a line to the CHANGELOG.rst file in the Unreleased section under the most fitting heading (e.g. Added, Changed, Removed).
  • I ran all tests on my computer and it's all green (i.e. invoke test).
  • I ran lint on my computer and there are no errors (i.e. invoke lint).
  • I added new functions/classes and made them available on a second-level import, e.g. compas_fab.robots.CollisionMesh.
  • I have added tests that prove my fix is effective or that my feature works.
  • I have added necessary documentation (if appropriate)

@beverlylytle
Copy link
Member Author

I thought I'd open a draft pull request to get commentary on choices I've made so far, before I start adding docstrings and tests. First big change is naming. Second is decoupling the way the ids are generated (and their values) from anything in the Plan. Third, I've added a method to remove planned actions. I'm debating the necessity of namespace and thinking of removing it unless otherwise convinced. I was thinking also of adding the methods check_for_cycles and sorted. Or is that premature optimization?

@yck011522
Copy link
Contributor

@beverlylytle do you mind short elaborate what the Action class and Plan class is supposed to hold?

Is an Action a movement waiting to be path planned? Is it a supposed to hold like a initial config and target config?

]


class IntegerIdGenerator(object):
Copy link
Member

@gonzalocasas gonzalocasas Apr 1, 2021

Choose a reason for hiding this comment

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

Would it make sense to make this thread-safe, like the SequenceCounter in rrc?
EDIT: https://github.com/compas-rrc/compas_rrc/blob/main/src/compas_rrc/client.py#L25

Copy link
Member Author

Choose a reason for hiding this comment

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

yep

Copy link
Member Author

Choose a reason for hiding this comment

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

Is this new version better?

@beverlylytle
Copy link
Member Author

Is an Action a movement waiting to be path planned? Is it a supposed to hold like a initial config and target config?

This whole thing is supposed to be very generic. An Action can hold whatever the user wants in parameters. It could be the location and mesh of brick, and then the Plan could represent the assembly sequence. Or it could be that the Action holds a ROSmsg and then the Plan represents the fabrication schedule. It really is suppose to hold the data for anything that can be partially ordered.

@yck011522
Copy link
Contributor

Is an Action a movement waiting to be path planned? Is it a supposed to hold like a initial config and target config?

This whole thing is supposed to be very generic. An Action can hold whatever the user wants in parameters. It could be the location and mesh of brick, and then the Plan could represent the assembly sequence. Or it could be that the Action holds a ROSmsg and then the Plan represents the fabrication schedule. It really is suppose to hold the data for anything that can be partially ordered.

Sorry, I don't fully get it, can you maybe give the intended use example you have in mind for the three classes Action, PlannedAction and Plan. I guess Plan holes an ordered list of PlannedAction? But the use case of Action is not so clear to me. Do I start with a Action. and pass it to a planner somehow, and I will get a PlannedAction in return? And how is the dependency_ids used? Is it like a tree structure for seeking out dependent Actions that are not planned and plan those first?

I dont see any information how these classes connect with the planners, so amybe I'm completely guessing wrong.

@beverlylytle
Copy link
Member Author

Ah, sorry, sure. Yeah, this has nothing to do with any of the planners. This is purely a data structure for holding a plan, and with no algorithmic planning capacity.

So, imagine you have three Bricks (bricks = [brick_1, brick_2, brick_3]) and these bricks are to be stacked into a pyramid with a base of brick_1 and brick2 and with brick_3 on top. You could model the assembly sequence with Plan by creating an action for each brick:

actions = {}
for i, brick in enumerate(bricks):
    name = 'brick_{}'.format(i)
    parameters = {'brick': brick}
    action = Action(name, parameters)
    actions[name] = action

Now, we need to plan these actions. The idea is that some Actions should "occur" before other Actions, but they don't necessarily form a linear sequence. For example, the first two bricks can be place in either order or even simultaneously. Since they are dependent on nothing, they get empty dependency_ids.

plan = Plan()
brick_1_action_id = plan.plan_action(actions['brick_1'], [])
brick_2_action_id = plan.plan_action(actions['brick_2'], [])

But the third brick sits on top of the other two, so for it to be a sensical assembly sequence, they would have to be placed before this third brick. So the third brick depends on the the first two.

brick_3_action_id = plan.plan_action(actions['brick_3'], [brick_1_action_id, brick_2_action_id])

Now we have a Plan with three PlannedActions that were created with plan_action our Actions. This can be thought of as a graph with the PlannedActions forming the nodes, an Action being the content of a node and the dependency_ids pointing to the parents of the node. This is not a tree because there could be multiple "roots" and could be disconnected, but it should be acyclic. (The current implementation doesn't enforce that, but that is the intent of this structure.) So in the above example, brick 3 points to bricks 1 and 2. This gives a partial order on the three bricks, which can be sorted into a linear order, but doesn't have to be.

It would also be possible to traverse this dependency graph (using threading) to complete some tasks held in each action (say, emit some ros messages) in a partially ordered manner. So in the above example, ros messages could be attached to each brick's action for picking and placing that brick. Traversing the graph, messages would be emitted for the picking and placing of bricks 1 and 2, allowing for parallel movement of two robots, and then the ros messages for picking and placing brick 3 wouldn't start until feedback had been received that the tasks for actions 1 and 2 were complete.

Does that make more sense @yck011522 ?

@beverlylytle beverlylytle marked this pull request as ready for review April 8, 2021 09:52
planned_actions : list of :class:`compas_fab.datastructures.PlannedAction`
The planned actions will be stored as an ordered dictionary, so their
ids should be distinct. Defaults to an empty list.
id_generator : Generator[Hashable, None, None]
Copy link
Member Author

Choose a reason for hiding this comment

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

Unsure about the typing here, and how sphinx wants it...

Copy link
Member

@gonzalocasas gonzalocasas left a comment

Choose a reason for hiding this comment

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

I'm leaving some initial feedback, but I'm yet to test this on the project that cannot be named! ;)

CHANGELOG.rst Outdated Show resolved Hide resolved
src/compas_fab/datastructures/__init__.py Outdated Show resolved Hide resolved
src/compas_fab/datastructures/plan.py Outdated Show resolved Hide resolved
src/compas_fab/datastructures/plan.py Outdated Show resolved Hide resolved
return 'Found invalid dependency ids {}'.format(invalid_ids)


class Plan(Datastructure):
Copy link
Member

Choose a reason for hiding this comment

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

Here I have a big question: why not inherit from the compas Graph instead? Otherwise, we will definitely duplicate features. As an example, I would almost immediately need to be able to turn a plan back and forth from a NetworkX DiGraph, and that works with Graph but not if the object inherits directly from Datastructure. The alternative would be containment of a Graph, but I am not sure that'd be very clean.

self.planned_actions[action_id] = planned_action
return action_id

def append_action(self, action):
Copy link
Member

Choose a reason for hiding this comment

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

I find the pair plan_action and append_action not very intuitive as being a pair of related functions.
Why not merge them both into add_action, if the dependency_ids param is none/empty, then do the appending logic?

Copy link
Member Author

Choose a reason for hiding this comment

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

I wanted to make a bigger distinction between dependency_ids=None and dependency_ids=[] because they result in very different behavior and I can foresee that being the source of annoying bugs for the user.

return dict(
action_id=self.id,
action=self.action,
# sets are not json serializable
Copy link
Member

Choose a reason for hiding this comment

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

A message for our future selves... ;)

src/compas_fab/datastructures/plan.py Outdated Show resolved Hide resolved
src/compas_fab/datastructures/plan.py Outdated Show resolved Hide resolved

Returns
-------
:obj:`list` of :obj:`list of :class:`compas_fab.datastructure.Action`
Copy link
Member Author

Choose a reason for hiding this comment

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

Suggested change
:obj:`list` of :obj:`list of :class:`compas_fab.datastructure.Action`
:obj:`list` of :obj:`list` of :class:`compas_fab.datastructure.Action`

other_plan_data = compas.json_loads(plan_json)
other_plan = Plan.from_data(other_plan_data)
assert plan.actions.keys() == other_plan.actions.keys()
assert other_plan.get_action(1).parameters['param'] == Frame.worldXY()
Copy link
Member Author

Choose a reason for hiding this comment

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

@yck011522 Not sure what you mean about Action.from_data not working... This test passes. What sort of test do you think would fail?

@yck011522
Copy link
Contributor

@beverlylytle

Thanks to your explanation with example. I have a feeling you are trying to do a very specific thing in your project and tries to generalize whatever that is to a bigger class that some others can use it.

At the moment I see the only functions for this class are to manage dependency. Which as @gonzalocasas pointed out is pretty much a compas Graph. Even your example with the 3 bricks can be replicated in some way with a Graph and Graph can hold arbitrary data too. In that way, I'm not sure what extra functions can users use here with your new 'Plan' class or how this class interact with other things in compas_fab such as the planning backends. Since I have mis-interpreted (maybe intentionally) that this is a path 'planning' related, maybe I would say, if this Plan class is designed to hold a un-Planned and Planned trajectory, then it would be useful.

I am guessing your intention for this class is to be able to have a partial plan and allow the computer to reason which things to plan first and what is next. And somehow the first plan changes the state of the scene or the robot such that the next planning depends on the result of previous plan(s). Hence the need for dependency or ordering.

If this is the use case, maybe I share a little how I handle this case in my own code. I have a Scene class that keeps track of each object's state (their location as in transformation, their kinematic configuration if it is a RobotModel) it holds the state of the entire scene. I also have an Action class, I guess similar to your Plan. I have a dependency relationship between Actions and I can interpret that to create a single linear ordered list of Actions. Some Actions in this list require path planning, some doesn't. If they do, they can also store other path planning relation information such as AllowedCollisionMatrix. After I plan one action with a singnature similar to PlanAction(Scene, Action) -> PlannedAction, I can update the Scene using the PlannedAction. This for example ensures my robot's location at the end of one Action will be the same in the beginning of next Action.

I have a feeling if this is the intention, it would be better to add these Scene and ObjectState related classed and have functions that allow the Actions/Plans and Scene states to interact. And perhaps the function to Path Plan with these Plans and States. I don't want to be discouraging and I appreciate this new PR, but I think more functions that allow these classes to interact with each other (and also with compas_fab classes) would be useful to compas_fab users.

@beverlylytle
Copy link
Member Author

The purpose of this data structure is to extend Graph to make it easier to create and maintain a graph that is directed and acyclic, representing a partial order on the contents of the nodes of the graph. So this structure is somewhere between a free-for-all graph and a rigid list. Of course everything that is done with this class can be done with Graph. Hell, every class in compas can be represented by a dictionary, but it would be super cumbersome to work always with something as primitive as a dictionary and bag of functions. It seems like the name "Plan" is misleading, as this class has no direct link to path planning. I liked it because it's short, ease to type and suggests something broad and generic (like "list" or "graph"). "DAG" (directed acyclic graph) also hits those notes to some degree, but is less accessible. I am open to suggestions on the name.

"Action" is also up for review. It is really just a wrapper around a dictionary. The fact that it is a class and that Python is dynamic provides for some syntactic sugar. But it's not much more than a node in Graph, to the point that I wonder if it even should exist.

Since the intention of the class is to be nearly as generic as Graph, I wouldn't expect the Plan to know about Scenes and ObjectStates and managing interactions between them just as Graph doesn't know about Scenes or Frames or Meshes and doesn't manage interactions between them. I think the kind of thing you are suggesting would be well accomplished by applying some variation on the Visitor Pattern to a Graph/Plan. Each user of the Plan could design their own visitors which would allow them to have tailor-made interactions between the contents of the nodes with the contents of other nodes or with external objects like Scenes. This kind of decoupling of data structure from function, and decoupling of data structure from other data structure, allows for much more flexible and reusable code. I am extremely reluctant to add any feature which treats an Action has anything but a container, or which has Plan do anything but manage storing these containers and pointers between them.

@gonzalocasas what do you think?

@beverlylytle
Copy link
Member Author

I'm reading over your comment again @yck011522 and it really sounds to me like you are describing a finite state machine, which is not at all what I'm trying to accomplish with this PR. Or do I misunderstand?

Comment on lines 233 to 252
def check_for_cycles(self):
""""Checks whether cycles exist in the dependency graph."""
from networkx import find_cycle
from networkx import NetworkXNoCycle
try:
cycle = find_cycle(self.networkx)
except NetworkXNoCycle:
return
raise Exception("Cycle found with edges {}".format(cycle))

def get_linear_sort(self):
"""Sorts the planned actions linearly respecting the dependency ids.

Returns
-------
:obj:`list` of :class:`compas_fab.datastructure.Action`
"""
from networkx import lexicographical_topological_sort
self.check_for_cycles()
return [self.get_action(action_id) for action_id in lexicographical_topological_sort(self.networkx)]
Copy link
Member Author

Choose a reason for hiding this comment

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

Do we want these to work in Ironpython?

@yck011522
Copy link
Contributor

yck011522 commented Apr 16, 2021

@beverlylytle I guess I understand it better when you say it's a directed acyclic graph. Maybe the name Plan really gave me high hopes of what the intention of this class wants to do and have me think about automatic planning.

Then there are just two things in my mind, regarding the naming I think descriptive names are more important than sounding elegant how about call it DirectedAccylicGraph. Second thing is why is this class in compas_fab but not in compas? I guess if the intention is generic and if there is nothing specific about robotics nor fabrication, then why is it here? I think these two are interelated, can choose to solve either one, it is just that when I look at compas_fab I expect robotic fabrication related classes and functions. And since most people use the package for path planning purposes, I would expect a very high level class with a name called Plan to do what it says. Hold a trajectory or something similar. I wouldn't want to call this DAG an Action too. An action must be actionable to make sense, and your intention seems to be more generic than that.

@gonzalocasas
Copy link
Member

Ok, let's see if we can sort this out in a way that satisfies all parties ;)
I think it's clear that Plan -despite being short and sweet- is not an accurate name. On the other hand, I don't like much the idea of calling it DAG because then it's too generic and we actually have a concrete application for it. If -eventually- we see the need of extracting a generic DAG out of this, we can do so, move that to compas core, and subclass it here. But let's work first on the concrete, and generalize later.

So, the core features of this new class are: 1) to specify "units of stuff that occur", 2) specify their dependencies, 3) extract those "units of stuff" in some order. There are two names that are very closely related but come from different disciplines, ones is the partially ordered set stuff from category theory and the other is partial order plans, and this class is somewhere in between. It lacks the solver parts for automated planning that would make it a better for for the latter option, so, I'm leaning towards PartialOrderSet or just PartialOrder (as in, this is just an ordering) (technically poset would also be possible, but that's cryptic imo).

Then the other important part is how to expose the traversal of the partial order. The options for traversal are probably very varied. Besides the classic algorithms for traversal, I can think of at least one that is event-based to determine triggering of dependencies (as in the GraphScheduler that we have somewhere else). So, I have the feeling a set of classes based on something like PartialOrderIterator might be a good idea, and those could contain what is currently in the lexicographical/topological/etc get_whatever_sort methods.

@beverlylytle beverlylytle changed the title Introducing Plan Introducing PartialOrder May 25, 2021
@beverlylytle
Copy link
Member Author

Alright, I've renamed it to PartialOrder.

Then the other important part is how to expose the traversal of the partial order. The options for traversal are probably very varied. Besides the classic algorithms for traversal, I can think of at least one that is event-based to determine triggering of dependencies (as in the GraphScheduler that we have somewhere else). So, I have the feeling a set of classes based on something like PartialOrderIterator might be a good idea, and those could contain what is currently in the lexicographical/topological/etc get_whatever_sort methods.

While it is important for the use of this data structure, I'm not sure how this comment fits into this PR. I've included two convenience methods for forwarding calls to networkx functions, which return some linear orderings, but I wouldn't call these traversals. I would think that there could be several ways one might want to traverse a PartialOrder. The threaded GraphScheduler or some sort of visitor pattern... But that seems like it would be specific to the project...

@yck011522
Copy link
Contributor

The new name PartialOrder sounds great to me. Clear and descriptive. Perhaps it would be good to keep a link to the Wiki page https://en.wikipedia.org/wiki/Partial-order_planning in the class description. Cheers

@yck011522
Copy link
Contributor

Regarding visitor pattern, is there no generic visitor pattern we can include in the default implementation?

@beverlylytle
Copy link
Member Author

I'd be happy to add an accept(visitor) method to PartialOrder in a way that starts at the sources of PartialOrder, then propagates the visitor down to the children and then also add an abstract method accept(visitor) to Action, but I'm unsure how to make a visitor interface/template because that would probably be very specific to the project and to the different instantiations of Action that a user might want to use.

@yck011522
Copy link
Contributor

Sounds good to me. I think this abstract method need some documentation for dummies. I am guessing if I want to use this visitor pattern, I need to overwrite the Action.accept(visitor) method, the visitor can be anything, that just get passed from the PartialOrder.accept() downwards. Not sure if I guess right?

Also, does your implementation always visit each node one time? (Not sure if I Or maybe that is something the user has to deal with. I'm also not sure how the source_first works.

Some other thoughts
In the use case which I can imagine, I will use, I would use this Partial Order to describe my assembly task, using the dependency to model relationships between Actions. Check if they are acyclic and then traverse it once from Child to Parent, In that way, I can plan the Actions that have no dependency first. In the case where each plan always has a solution, I need to traverse each node only once.

However, if I anticipate planning can fail due to a bad Child solution, I might want some sort of backtracking ability. I am not knowledgeable enough to say how this can be implemented tho. But maybe there is some elegant way. I think this pattern is very generalizable and often used.

action can be thought of as pointers to the parents of that planned action.
While actions can be added and removed using the methods of
:attr:`compas_fab.datastructures.PartialOrder.graph`, it is strongly recommended
that the methods ``plan_action``, ``append_action`` and ``remove_action``
Copy link
Contributor

Choose a reason for hiding this comment

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

do you mean get_action instead of plan_action?

Another thought is, how about renaming PartialOrder.get_action() to PartialOrder.action()? Is there some meaning associated with the get_ prefix?

Copy link
Member Author

Choose a reason for hiding this comment

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

Actually it should be add_action not plan_action.

As for get_action, the idea is that you provide the key and get_action "gets" you the action, that is returns the Action instance associated to that key. It's just a typical python thing to name methods with verbs, but some frown on using get_.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for clarifying, I just somehow had this idea where if there is a get_ prefix, then I should not modify the returned result in place but rather use the corresponding set_ method. But I could be hallucinating with other language flavours.

@beverlylytle
Copy link
Member Author

Also, does your implementation always visit each node one time? (Not sure if I Or maybe that is something the user has to deal with. I'm also not sure how the source_first works.

Yes, as it's written now, each node is visited exactly one. The source_first flag indicates whether you want the visitor to do it's thing on a node and then visit the children or the other way around. That is, does the visitor start at the sources of the dag or at the sinks of the dag?

I'm sure there's a way to pass a visitor around in a graph anyway you want, including ways that result in infinite loops. However, the accept method that's in this draft, is top down or bottom up and nothing in between. But it is a draft and I am open to suggestion. However, it sounds like you could make do with what's in this draft by using multiple passes on your PartialOrder with visitors, with one doing the initial work, and another doing cleanup.

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

Successfully merging this pull request may close these issues.

Add partial order plan datastructure
3 participants