diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 69ec25dc48..ec00420bf2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -52,6 +52,7 @@ Unreleased * Grasshopper components now also for Mac * Added support for MoveIt on ROS Noetic * Added support for Python 3.9 +* In ``compas.datastructures``, added ``Plan``, ``Action`` and ``IntegerIdGenerator`` **Changed** diff --git a/setup.py b/setup.py index a8ca29dfeb..fcac671cfd 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ from setuptools import find_packages, setup requirements = [ - 'compas>=1.3,<2.0', + 'compas>=1.5,<2.0', 'roslibpy>=1.1.0', 'pybullet', 'pyserial', diff --git a/src/compas_fab/__init__.py b/src/compas_fab/__init__.py index d3da99b7a7..0a8a9b52d4 100644 --- a/src/compas_fab/__init__.py +++ b/src/compas_fab/__init__.py @@ -14,6 +14,7 @@ compas_fab.backends compas_fab.robots + compas_fab.datastructures compas_fab.utilities compas_fab.sensors compas_fab.blender diff --git a/src/compas_fab/datastructures/__init__.py b/src/compas_fab/datastructures/__init__.py new file mode 100644 index 0000000000..6c4fbe5e2a --- /dev/null +++ b/src/compas_fab/datastructures/__init__.py @@ -0,0 +1,35 @@ +""" +******************************************************************************** +compas_fab.datastructures +******************************************************************************** + +.. currentmodule:: compas_fab.datastructures + +PartialOrder +----- + +.. autosummary:: + :toctree: generated/ + :nosignatures: + + Action + DependencyIdException + IntegerIdGenerator + PartialOrder + +""" + +from .partial_order import ( + Action, + DependencyIdException, + IntegerIdGenerator, + PartialOrder +) + + +__all__ = [ + 'Action', + 'DependencyIdException', + 'IntegerIdGenerator', + 'PartialOrder', +] diff --git a/src/compas_fab/datastructures/partial_order.py b/src/compas_fab/datastructures/partial_order.py new file mode 100644 index 0000000000..c85f1627ab --- /dev/null +++ b/src/compas_fab/datastructures/partial_order.py @@ -0,0 +1,349 @@ +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.data import Data +from compas.datastructures import Datastructure +from compas.datastructures import Graph + +__all__ = [ + 'Action', + 'DependencyIdException', + 'IntegerIdGenerator', + 'PartialOrder', +] + + +class IntegerIdGenerator(Data): + """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, pretty=False): + compas.json_dump(self.data, filepath, pretty=pretty) + + +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 PartialOrder(Datastructure): + """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.PartialOrder.graph`, it is strongly recommended + that the methods ``add_action``, ``append_action`` and ``remove_action`` + are used instead. + + Attributes + ---------- + graph : :class:`compas.datastructures.Graph + id_generator : Generator[Hashable, None, None] + 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`. + + Notes + ----- + See . + """ + def __init__(self, id_generator=None): + super(PartialOrder, 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 add_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): + """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.add_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)] + + 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` + """ + 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)] + + def accept(self, visitor, source_first=True): + sources = [key for key in self.graph.nodes() if len(self.graph.neighbors_in(key)) == 0] + for source in sources: + self._accept(source, visitor, source_first) + + def _accept(self, key, visitor, source_first): + action = self.get_action(key) + if source_first: + action.accept(visitor) + for child in self.graph.neighbors_out(key): + self._accept(child, visitor, source_first) + if not source_first: + action.accept(visitor) + + @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(Data): + """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'.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): + 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, pretty=False): + compas.json_dump(self.data, filepath, pretty=pretty) + + def copy(self, cls=None): + if not cls: + cls = type(self) + return cls.from_data(deepcopy(self.data)) + + def accept(self, visitor): + raise NotImplementedError diff --git a/tests/datastructures/test_partial_order.py b/tests/datastructures/test_partial_order.py new file mode 100644 index 0000000000..86525dc207 --- /dev/null +++ b/tests/datastructures/test_partial_order.py @@ -0,0 +1,163 @@ +from itertools import count + +import compas +import pytest + +from compas.geometry import Frame +from compas_fab.datastructures import Action +from compas_fab.datastructures import IntegerIdGenerator +from compas_fab.datastructures import PartialOrder +from compas_fab.datastructures import DependencyIdException + + +def test_action_data(): + name = "action" + parameters = {'param1': 1, 'param2': Frame.worldXY()} + action = Action(name, parameters) + other_action = Action.from_data(action.data) + assert action.name == other_action.name + assert action.parameters == other_action.parameters + + action_json = compas.json_dumps(action.data) + other_action_data = compas.json_loads(action_json) + other_action = Action.from_data(other_action_data) + assert other_action.parameters['param2'] == Frame.worldXY() + + +def test_integer_id_generator(): + generator = IntegerIdGenerator() + assert next(generator) == 1 + assert next(generator) == 2 + generator_json = compas.json_dumps(generator.data) + generator_data = compas.json_loads(generator_json) + generator_reincarnate = IntegerIdGenerator.from_data(generator_data) + assert next(generator_reincarnate) == 3 + + +def test_partial_order(): + partial_order = PartialOrder() + assert len(partial_order.actions) == 0 + + +def test_partial_order_generator_compatibility(): + action = Action('action', {'param': 3}) + partial_order = PartialOrder() + action_id = partial_order.add_action(action, []) + assert action_id == 1 + assert next(partial_order._id_generator) == 2 + + +def test_partial_order_with_custom_id_generator(): + class CustomIdGenerator(object): + def __init__(self): + self._generator = count(ord('a')) + + def __next__(self): + return chr(next(self._generator)) + + # alias for ironpython + next = __next__ + + partial_order = PartialOrder(CustomIdGenerator()) + action_a = Action('action_a', {}) + partial_order.add_action(action_a, []) + action_b = Action('action_b', {}) + partial_order.add_action(action_b, ['a']) + assert 'a' in partial_order.actions + assert 'b' in partial_order.actions + assert ['a'] == partial_order.get_dependency_ids('b') + partial_order.remove_action('a') + assert 'a' not in partial_order.actions + assert 'b' in partial_order.actions + assert [] == partial_order.get_dependency_ids('b') + action_c = Action('action_c', {}) + partial_order.append_action(action_c) + assert 'c' in partial_order.actions + assert ['b'] == partial_order.get_dependency_ids('c') + + +def test_partial_order_data(): + partial_order = PartialOrder() + partial_order.append_action(Action('action_1', {'param': Frame.worldXY()})) + other_partial_order = PartialOrder.from_data(partial_order.data) + assert partial_order.actions.keys() == other_partial_order.actions.keys() + + # the data attributes point to the same generator, + # so no testing `append_action` yet + partial_order_json = compas.json_dumps(partial_order.data) + other_partial_order_data = compas.json_loads(partial_order_json) + other_partial_order = PartialOrder.from_data(other_partial_order_data) + assert partial_order.actions.keys() == other_partial_order.actions.keys() + other_frame = other_partial_order.get_action(1).parameters['param'] + assert other_frame == Frame.worldXY() + + # now there are two generators + partial_order.append_action(Action('action_2')) + other_partial_order.append_action(Action('other_action_2')) + assert partial_order.actions.keys() == other_partial_order.actions.keys() + + +def test_partial_order_action(): + partial_order = PartialOrder() + action_1_id = partial_order.add_action(Action('action_1'), []) + assert action_1_id == 1 + assert partial_order.get_action(action_1_id).name == 'action_1' + action_2_id = partial_order.add_action(Action('action_2'), [action_1_id]) + assert action_2_id == 2 + assert partial_order.get_action(action_2_id).name == 'action_2' + with pytest.raises(DependencyIdException): + partial_order.add_action(Action('action_impossible'), [5]) + + +def test_append_action(): + partial_order = PartialOrder() + action_1_id = partial_order.append_action(Action('action_1')) + assert action_1_id == 1 + assert partial_order.get_action(action_1_id).name == 'action_1' + assert partial_order.get_dependency_ids(action_1_id) == [] + action_2_id = partial_order.append_action(Action('action_2')) + assert action_2_id == 2 + assert partial_order.get_action(action_2_id).name == 'action_2' + assert partial_order.get_dependency_ids(action_2_id) == [action_1_id] + with pytest.raises(DependencyIdException): + partial_order.add_action(Action('action_impossible'), [5]) + + +def test_remove_action(): + partial_order = PartialOrder() + action_1_id = partial_order.append_action(Action('action_1')) + action_2_id = partial_order.append_action(Action('action_2')) + action_3_id = partial_order.append_action(Action('action_3')) + assert partial_order.get_dependency_ids(action_2_id) == [action_1_id] + assert partial_order.get_dependency_ids(action_3_id) == [action_2_id] + partial_order.remove_action(action_2_id) + assert action_2_id not in partial_order.actions + assert partial_order.get_dependency_ids(action_3_id) == [] + + +def test_check_dependency_ids(): + partial_order = PartialOrder() + with pytest.raises(DependencyIdException): + partial_order.check_dependency_ids([1]) + id_1 = partial_order.append_action(Action('action_1')) + partial_order.check_dependency_ids([id_1]) + id_2 = partial_order.append_action(Action('action_2')) + partial_order.check_dependency_ids([id_1]) + partial_order.check_dependency_ids([id_1, id_2]) + with pytest.raises(DependencyIdException): + partial_order.check_dependency_ids([3]) + with pytest.raises(DependencyIdException): + partial_order.check_dependency_ids([id_1, 5]) + + +def test_check_all_dependency_ids(): + partial_order = PartialOrder() + partial_order.check_all_dependency_ids() + partial_order.add_action(Action('action_1'), []) + partial_order.add_action(Action('action_2'), [1]) + partial_order.check_all_dependency_ids() + partial_order.graph.add_node(3, action=Action('action_3')) + partial_order.graph.add_edge(2, 3) + partial_order.graph.add_edge(5, 3) + with pytest.raises(Exception): + partial_order.check_all_dependency_ids()