Skip to content

Commit

Permalink
Merge pull request #5 from compas-dev/background-worker
Browse files Browse the repository at this point in the history
Background task component
  • Loading branch information
gonzalocasas authored Nov 1, 2023
2 parents b6a7235 + 4ec5768 commit 5fc088c
Show file tree
Hide file tree
Showing 21 changed files with 475 additions and 8 deletions.
5 changes: 4 additions & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ message = Bump version to {new_version}
commit = True
tag = True


[bumpversion:file:docs/installation.rst]
search = {current_version}
replace = {new_version}
Expand All @@ -24,3 +23,7 @@ replace = __version__ = "{new_version}"
[bumpversion:file:CHANGELOG.md]
search = Unreleased
replace = [{new_version}] {now:%Y-%m-%d}

[bumpversion:glob:src/compas_eve/ghpython/components/**/code.py]
search = v{current_version}
replace = v{new_version}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,6 @@ temp/**
docs/api/generated/

conda.recipe/

# Grasshopper generated objects
src/compas_eve/ghpython/components/ghuser/*.ghuser
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

* Added examples and more detailed documentation for installation and usage.
* Added `BackgroundWorker` class and `BackgroundTask` Grasshopper component to execute long-running tasks without blocking GH.

### Changed

Expand Down
1 change: 0 additions & 1 deletion docs/_images/PLACEHOLDER

This file was deleted.

Binary file added docs/_images/background-task.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/_images/pubsub.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ API Reference
api/compas_eve
api/compas_eve.mqtt
api/compas_eve.memory
api/compas_eve.ghpython

2 changes: 2 additions & 0 deletions docs/api/compas_eve.ghpython.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@

.. automodule:: compas_eve.ghpython
5 changes: 5 additions & 0 deletions docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ Examples

.. currentmodule:: compas_eve

.. note::

This tutorial assumes that you have already installed ``compas_eve``.
If you haven't, please follow the instructions in the :ref:`installation` section.

The main feature of ``compas_eve`` is to allow communication between different
parts of a program using messages. These messages are sent around using a
publisher/subscriber model, or pub/sub for short. In pub/sub communication,
Expand Down
76 changes: 76 additions & 0 deletions docs/grasshopper.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
********************************************************************************
Examples in Rhino/Grasshopper
********************************************************************************

.. currentmodule:: compas_eve

.. note::

This tutorial assumes that you have already installed ``compas_eve``.
If you haven't, please follow the instructions in the :ref:`installation` section.

**COMPAS EVE** provides tools to work with events inside Rhino/Grasshopper, as well as
the ability to run long-running tasks in the background, which would otherwise block the UI.

Long-running tasks
------------------

A long-running task is any snippet of code that takes a long time to execute. Normally, this would
freeze the Grasshopper user interface. **COMPAS EVE** provides a mechanism to run such tasks in the
background, so that the user can continue working with Grasshopper while the task is running.

In order to use it, add a ``Background task`` component to your Grasshopper definition, and connect
an input with a python function containing the code that needs to run in the background. The only
requirement is that this function must accept a ``worker`` argument, which is an instance of
:class:`~compas_eve.ghpython.BackgroundWorker`.

.. figure:: /_images/background-task.png
:figclass: figure
:class: figure-img img-fluid

The following code exemplifies how to use it to create a simple background task that generates
a list of random values. The function adds some delay to simulate a long-running task.

.. code-block:: python
import time
import random
def do_something(worker):
result = []
for i in range(100):
result.append(random.random())
worker.display_progress(len(result) / 100)
time.sleep(0.05)
worker.display_message("Done!")
return result
It is also possible to update the results during the execution of the task. The result
can be of any type, in the previous example it was a list of numbers.

In the following example, the code generates a list of randomly placed Rhino points
and continuously updates the results as the list grows. The points will appear
in the Rhino Viewport even before the task has completed.

.. code-block:: python
import time
import random
import Rhino.Geometry as rg
def do_something(worker):
result = []
for i in range(100):
x, y = random.randint(0, 100), random.randint(0, 100)
result.append(rg.Point3d(x, y, 0))
worker.update_result(result)
time.sleep(0.01)
worker.display_message("Done!")
return result
7 changes: 4 additions & 3 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ Event Extensions for COMPAS

``compas_eve`` adds event-based communication infrastructure to the COMPAS framework.

.. .. figure:: /_images/
:figclass: figure
:class: figure-img img-fluid
.. figure:: /_images/pubsub.png
:figclass: figure
:class: figure-img img-fluid


Table of Contents
Expand All @@ -21,6 +21,7 @@ Table of Contents
Introduction <self>
installation
examples
grasshopper
api
license

Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
compas
compas>=1.17.6
paho-mqtt
2 changes: 1 addition & 1 deletion src/compas_eve/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ class Topic(object):
options : dict
A dictionary of options.
"""
# TODO: Add documentation/examples of possible options

# TODO: Add documentation/examples of possible options

def __init__(self, name, message_type, **options):
self.name = name
Expand Down
20 changes: 20 additions & 0 deletions src/compas_eve/ghpython/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""
********************************************************************************
compas_eve.ghpython
********************************************************************************
.. currentmodule:: compas_eve.ghpython
Classes
=======
.. autosummary::
:toctree: generated/
:nosignatures:
BackgroundWorker
"""
from .background import BackgroundWorker

__all__ = ["BackgroundWorker"]
211 changes: 211 additions & 0 deletions src/compas_eve/ghpython/background.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import threading

import Rhino
import scriptcontext
import System
from compas_ghpython import create_id
from compas_ghpython import update_component


class BackgroundWorker(object):
"""Background worker simplifies the creation of long-running tasks inside Grasshopper.
A long-running task is any piece of code that will run for an extended period of time,
for example, a very complex calculation, or loading a very large data file, etc.
To use it, write your long-running function in a Grasshopper GHPython component,
and pass it as the input to the background worker component.
The worker will continue working without blocking the UI.
The following is an example of a long-running function that updates the
progress while it runs.
.. code-block:: python
import time
def do_something_long_and_complicated(worker):
# Result can be of any data type
result = 0
for i in range(50):
worker.current_value = i
result += i
worker.display_progress(i / (50-1))
time.sleep(0.01)
worker.display_message("Done!")
return result
Parameters
----------
ghenv : ``GhPython.Component.PythonEnvironment``
Grasshopper environment object
long_running_function : function, optional
This function will be the main entry point for the long-running task.
"""

def __init__(self, ghenv, long_running_function=None):
super(BackgroundWorker, self).__init__()
self.ghenv = ghenv
self._is_working = False
self._is_done = False
self._is_cancelled = False
self._has_requested_cancellation = False
self.long_running_function = long_running_function

def is_working(self):
"""Indicate whether the worker is currently working or not."""
return self._is_working

def is_done(self):
"""Indicate whether the worker is done or not."""
return self._is_done

def has_requested_cancellation(self):
return self._has_requested_cancellation

def request_cancellation(self):
"""Mark the current worker as cancelled, so that the background task can stop processing."""
self._has_requested_cancellation = True

def start_work(self):
"""Start the background processing thread where work will be performed."""

def _long_running_task_wrapper(worker):
try:
worker.set_internal_state_to_working()
result = self.long_running_function(self)
worker.set_internal_state_to_done(result)
except Exception as e:
worker.display_message(str(e))
worker.set_internal_state_to_cancelled()

target = _long_running_task_wrapper
args = (self,)
self.thread = threading.Thread(target=target, args=args)
self.thread.start()

def set_internal_state_to_working(self):
"""Set the internal state to ``working``."""
self._is_working = True
self._is_done = False
self._is_cancelled = False

def set_internal_state_to_done(self, result):
"""Set the internal state to ``done``, which indicates the worker has completed."""
self._is_working = False
self._is_done = True
self._is_cancelled = False
self.update_result(result, delay=1)

def update_result(self, result, delay=1):
"""Update the result of the worker.
This will update the result of the worker, and trigger a solution expiration
of the Grasshopper component.
Parameters
----------
result : object
Result of the worker.
delay : int, optional
Delay (in milliseconds) before updating the component, by default 1.
"""
self.result = result
update_component(self.ghenv, delay)

def set_internal_state_to_cancelled(self):
"""Set the internal state to ``cancelled``."""
self._is_working = False
self._is_done = False
self._is_cancelled = True

def display_progress(self, progress):
"""Display a progress indicator in the component.
Parameters
----------
progress : float
Float between ``0..1`` indicating progress of completion.
"""
self.display_message("Progress {:.1f}%".format(progress * 100))

def display_message(self, message):
"""Display a message in the component without triggering a solution expiration.
Parameters
----------
message : str
Message to display.
"""

def ui_callback():
self.ghenv.Component.Message = message
self.ghenv.Component.OnDisplayExpired(True)

Rhino.RhinoApp.InvokeOnUiThread(System.Action(ui_callback))

@classmethod
def instance_by_component(cls, ghenv, long_running_function=None, force_new=False):
"""Get the worker instance assigned to the component.
This will get a persistant instance of a background worker
for a given component. The parameter `force_new` can
be set to `True` to request a new instance to be created.
Parameters
----------
ghenv : ``GhPython.Component.PythonEnvironment``
Grasshopper environment object
long_running_function : function, optional
This function will be the main entry point for the long-running task.
force_new : bool, optional
Force the creation of a new background worker, by default False.
Returns
-------
:class:`BackgroundWorker`
Instance of the background worker of the current component.
"""

key = create_id(ghenv.Component, "background_worker")
worker = scriptcontext.sticky.get(key)

if worker and force_new:
worker.request_cancellation()
worker = None
del scriptcontext.sticky[key]

if not worker:
worker = cls(ghenv, long_running_function=long_running_function)
scriptcontext.sticky[key] = worker

return worker

@classmethod
def stop_instance_by_component(cls, ghenv):
"""Stops the worker instance assigned to the component.
If there is no worker running, it will do nothing.
Parameters
----------
ghenv : ``GhPython.Component.PythonEnvironment``
Grasshopper environment object
"""

key = create_id(ghenv.Component, "background_worker")
worker = scriptcontext.sticky.get(key)

if worker:
worker.request_cancellation()
worker = None
del scriptcontext.sticky[key]
Loading

0 comments on commit 5fc088c

Please sign in to comment.