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
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0aca75d
introducing Plan
beverlylytle Apr 1, 2021
da4fe74
lint
beverlylytle Apr 1, 2021
b7d8835
docstrings, inherit from compas.base.Base, safety checks
beverlylytle Apr 6, 2021
1c92c81
add tests, and some fixes
beverlylytle Apr 8, 2021
031e3a9
more tests
beverlylytle Apr 8, 2021
87c1a67
Update src/compas_fab/datastructures/plan.py
beverlylytle Apr 8, 2021
6824321
Update src/compas_fab/datastructures/plan.py
beverlylytle Apr 8, 2021
ed26c84
Update src/compas_fab/datastructures/plan.py
beverlylytle Apr 8, 2021
b79cb47
add alias for ironpython
beverlylytle Apr 8, 2021
f44387f
more ipy aliases
beverlylytle Apr 8, 2021
e00f63c
Update CHANGELOG.rst
beverlylytle Apr 12, 2021
e5a685a
Use compas.json_<load/dump>
beverlylytle Apr 12, 2021
164a68d
Inherit from Base rather than Datastructure
beverlylytle Apr 12, 2021
a88c502
remove extraneous from_data
beverlylytle Apr 12, 2021
ccbca4b
Add module level docstring
beverlylytle Apr 12, 2021
55ee2e8
Merge branch 'main' into dag
gonzalocasas Apr 12, 2021
2adbd02
Merge branch 'dag' of https://github.com/compas-dev/compas_fab into dag
beverlylytle Apr 12, 2021
d89ee63
use compas.datastructures.Graph
beverlylytle Apr 15, 2021
93f3952
locally import networkx to make ipy happy
beverlylytle Apr 15, 2021
9651875
fix changelog
beverlylytle Apr 15, 2021
f05da6f
extend test
beverlylytle Apr 15, 2021
8c8d637
better test
beverlylytle Apr 16, 2021
d4941f5
update to newest compas version
beverlylytle Apr 20, 2021
744ccba
Merge branch 'main' into dag
beverlylytle May 25, 2021
4fcb952
Rename Plan to PartialOrder
beverlylytle May 25, 2021
829926c
Rename more stuff
beverlylytle May 25, 2021
ab10364
Change test to make ironpython happy
beverlylytle May 26, 2021
23ae475
Add wikipedia link
beverlylytle May 26, 2021
07268fd
Added first draft of accept methods
beverlylytle May 26, 2021
a5341ad
Yeah, I remember how my code works
beverlylytle May 26, 2021
d28fb49
Update src/compas_fab/datastructures/partial_order.py
beverlylytle May 26, 2021
31b963a
Merge branch 'main' into dag
beverlylytle May 27, 2021
a9e96ab
Merge branch 'dag' of https://github.com/compas-dev/compas_fab into dag
beverlylytle May 27, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Unreleased

* Added support for MoveIt on ROS Noetic
* Added support for Python 3.9
* In ``compas.datastructures``, added ``Plan``, ``Action`` and ``IntegerIdGenerator``

**Changed**

Expand Down
35 changes: 35 additions & 0 deletions src/compas_fab/datastructures/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""
********************************************************************************
compas_fab.datastructures
********************************************************************************

.. currentmodule:: compas_fab.datastructures

Plan
-----

.. autosummary::
:toctree: generated/
:nosignatures:

Action
DependencyIdException
IntegerIdGenerator
Plan

"""

from .plan import (
gonzalocasas marked this conversation as resolved.
Show resolved Hide resolved
Action,
DependencyIdException,
IntegerIdGenerator,
Plan
)


__all__ = [
'Action',
'DependencyIdException',
'IntegerIdGenerator',
'Plan',
]
328 changes: 328 additions & 0 deletions src/compas_fab/datastructures/plan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import threading
from collections import OrderedDict
from copy import deepcopy
from itertools import count

import compas
from compas.base import Base
from compas.datastructures import Datastructure
from compas.datastructures import Graph

__all__ = [
'Action',
'DependencyIdException',
'IntegerIdGenerator',
'Plan',
]


class IntegerIdGenerator(Base):
"""Generator object yielding integers sequentially in a thread safe manner.

Parameters
----------
start_value : :obj:`int`
First value to be yielded by the generator.
"""
def __init__(self, start_value=1):
super(IntegerIdGenerator, self).__init__()
self.last_generated = start_value - 1
self._lock = threading.Lock()
self._generator = count(start_value)

def __next__(self):
with self._lock:
self.last_generated = next(self._generator)
return self.last_generated

# alias for ironpython
next = __next__

@property
def data(self):
return {
'start_value': self.last_generated + 1
}

def to_data(self):
return self.data

@classmethod
def from_data(cls, data):
return cls(data['start_value'])

@classmethod
def from_json(cls, filepath):
data = compas.json_load(filepath)
return cls.from_data(data)

def to_json(self, filepath):
compas.json_dump(self.data, filepath)


class DependencyIdException(Exception):
"""Indicates invalid ids given as dependencies."""
def __init__(self, invalid_ids, pa_id=None):
message = self.compose_message(invalid_ids, pa_id)
super(DependencyIdException, self).__init__(message)

@staticmethod
def compose_message(invalid_ids, pa_id):
if pa_id:
return 'Planned action {} has invalid dependency ids {}'.format(pa_id, invalid_ids)
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.

"""Data structure extending :class:`compas.datastructures.Graph` for creating
and maintaining a partially ordered plan (directed acyclic graph).
The content of any event of the plan is contained in an
:class:`compas_fab.datastructures.Action`. The dependency ids of a planned
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.Plan.graph`, it is strongly recommended
that the methods ``plan_action``, ``append_action`` and ``remove_action``
are used instead.

Attributes
----------
graph : :class:`compas.datastructures.Graph
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...

Object which generates keys (via ``next()``) for
:class:`compas_fab.datastructures.Action`s added using this object's
methods. Defaults to :class:`compas_fab.datastructures.IntegerIdGenerator`.
"""
def __init__(self, id_generator=None):
super(Plan, self).__init__()
self.graph = Graph()
self.graph.node = OrderedDict()
self._id_generator = id_generator or IntegerIdGenerator()

@property
def networkx(self):
"""A new NetworkX DiGraph instance from ``graph``."""
return self.graph.to_networkx()

@property
def actions(self):
"""A dictionary of id-:class:`compas_fab.datastructures.Action` pairs."""
return {action_id: self.get_action(action_id) for action_id in self.graph.nodes()}

def get_action(self, action_id):
"""Gets the action for the associated ``action_id``

Parameters
----------
action_id : hashable

Returns
-------
:class:`compas_fab.datastructures.Action`
"""
action = self.graph.node_attribute(action_id, 'action')
if action is None:
raise Exception("Action with id {} not found".format(action_id))
return action

def remove_action(self, action_id):
action = self.get_action(action_id)
self.graph.delete_node(action_id)
return action

def plan_action(self, action, dependency_ids):
"""Adds the action to the plan with the given dependencies,
and generates an id for the newly planned action.

Parameters
----------
action : :class:`comaps_fab.datastructures.Action`
The action to be added to the plan.
dependency_ids : set or list
The keys of the already planned actions that the new action
is dependent on.

Returns
-------
The id of the newly planned action.
"""
self.check_dependency_ids(dependency_ids)
action_id = self._get_next_action_id()
self.graph.add_node(action_id, action=action)
for dependency_id in dependency_ids:
self.graph.add_edge(dependency_id, action_id)
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.

"""Adds the action to the plan dependent on the last action added
to the plan, and generates an id for this newly planned action.

Parameters
----------
action : :class:`comaps_fab.datastructures.Action`
The action to be added to the plan.

Returns
-------
The id of the newly planned action.
"""
dependency_ids = set()
if self.graph.node:
last_action_id = self._get_last_action_id()
dependency_ids = {last_action_id}
return self.plan_action(action, dependency_ids)

def _get_last_action_id(self):
last_action_id, last_action_attrs = self.graph.node.popitem()
self.graph.node[last_action_id] = last_action_attrs
return last_action_id

def _get_next_action_id(self):
return next(self._id_generator)

def get_dependency_ids(self, action_id):
"""Return the identifiers of actions upon which the action with id ``action_id`` is dependent.

Parameters
----------
action_id : hashable
The identifier of the action.

Returns
-------
:obj:`list`
A list of action identifiers.

"""
return self.graph.neighbors_in(action_id)

def check_dependency_ids(self, dependency_ids, action_id=None):
"""Checks whether the given dependency ids exist in the plan.

Parameters
----------
dependency_ids : set or list
The dependency ids to be validated.
action_id : hashable
The id of the associated action. Used only in
the error message. Defaults to ``None``.

Raises
------
:class:`compas_fab.datastructures.DependencyIdException`
"""
dependency_ids = set(dependency_ids)
if not dependency_ids.issubset(self.graph.node):
invalid_ids = dependency_ids.difference(self.graph.node)
raise DependencyIdException(invalid_ids, action_id)

def check_all_dependency_ids(self):
"""Checks whether the dependency ids of all the planned actions
are ids of planned actions in the plan.

Raises
------
:class:`compas_fab.datastructures.DependencyIdException`
"""
for action_id in self.actions:
self.check_dependency_ids(self.get_dependency_ids(action_id), action_id)

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?


def get_all_linear_sorts(self):
"""Gets all possible linear sorts respecting the dependency ids.

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`

"""
from networkx import all_topological_sorts
self.check_for_cycles()
return [[self.get_action(action_id) for action_id in sorting] for sorting in all_topological_sorts(self.networkx)]

@property
def data(self):
return {
'graph': self.graph,
'id_generator': self._id_generator,
}

@data.setter
def data(self, data):
graph = data['graph']
graph.node = OrderedDict(graph.node)
self.graph = graph
self._id_generator = data['id_generator']


class Action(Base):
"""Abstract representation of an event independent of its timing.

Parameters
----------
name : :obj:`str`
The name of the action.
parameters : dict
Any other content associated to the action housed in key-value pairs.
"""
def __init__(self, name, parameters=None):
super(Action, self).__init__()
self.name = name
self.parameters = parameters or {}

def __str__(self):
return 'Action<name={}>'.format(self.name)

@property
def data(self):
return dict(
name=self.name,
parameters=self.parameters,
)

@data.setter
def data(self, data):
self.name = data['name']
self.parameters = data['parameters']

@classmethod
def from_data(cls, data):
beverlylytle marked this conversation as resolved.
Show resolved Hide resolved
return cls(**data)

def to_data(self):
return self.data

@classmethod
def from_json(cls, filepath):
data = compas.json_load(filepath)
return cls.from_data(data)

def to_json(self, filepath):
compas.json_dump(self.data, filepath)

def copy(self, cls=None):
if not cls:
cls = type(self)
return cls.from_data(deepcopy(self.data))
Loading