Skip to content

Commit

Permalink
Network Explorer: Reduce number of slow repaints during optimization
Browse files Browse the repository at this point in the history
  • Loading branch information
janezd committed Feb 7, 2024
1 parent 26adb3c commit bc4cfa8
Show file tree
Hide file tree
Showing 2 changed files with 150 additions and 1 deletion.
142 changes: 142 additions & 0 deletions orangecontrib/network/widgets/OWNxExplorer.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import time
import functools
from weakref import WeakKeyDictionary
from typing import Union, Optional, Callable

import numpy as np
import scipy.sparse as sp

Expand All @@ -20,6 +25,139 @@
FR_ALLOWED_TIME = 30


# This decorator doesn't belong here. When Orange 3.37 is released
# (hopefully with https://github.com/biolab/orange3/pull/6612), this code
# should be removed and the decorator imported from Orange.util.

# This should look like decorator, not a class, pylint: disable=invalid-name
class allot:
"""
Decorator that allows a function only a specified portion of time per call.
Usage:
```
@allot(0.2, overflow=of)
def f(x):
...
```
The above function is allotted 0.2 second per second. If it runs for 0.2 s,
all subsequent calls in the next second (after the start of the call) are
ignored. If it runs for 0.1 s, subsequent calls in the next 0.5 s are
ignored. If it runs for a second, subsequent calls are ignored for 5 s.
An optional overflow function can be given as a keyword argument
`overflow`. This function must have the same signature as the wrapped
function and is called instead of the original when the call is blocked.
If the overflow function is not given, the wrapped function must not return
result. This is because without the overflow function, the wrapper has no
value to return when the call is skipped.
The decorator adds a method `call` to force the call, e.g. by calling
f.call(5), in the above case. The used up time still counts for the
following (non-forced) calls.
The decorator also adds two attributes:
- f.last_call_duration is the duration of the last call (in seconds)
- f.no_call_before contains the time (time.perf_counter) when the next
call will be made.
The decorator can be used for functions and for methods.
A non-parametrized decorator doesn't block any calls and only adds
last_call_duration, so that it can be used for timing.
"""
def __new__(cls: type, arg: Union[None, float, Callable], *,
overflow: Optional[Callable] = None,
_bound_methods: Optional[WeakKeyDictionary] = None):
self = super().__new__(cls)

if arg is None or isinstance(arg, float):
# Parametrized decorator
if arg is not None:
assert arg > 0

def set_func(func):
self.__init__(func,
overflow=overflow,
_bound_methods=_bound_methods)
self.allotted_time = arg
return self

return set_func

else:
# Non-parametrized decorator
self.allotted_time = None
return self

def __init__(self,
func: Callable, *,
overflow: Optional[Callable] = None,
_bound_methods: Optional[WeakKeyDictionary] = None):
assert callable(func)
self.func = func
self.overflow = overflow
functools.update_wrapper(self, func)

self.no_call_before = 0
self.last_call_duration = None

# Used by __get__; see a comment there
if _bound_methods is None:
self.__bound_methods = WeakKeyDictionary()
else:
self.__bound_methods = _bound_methods

# If we are wrapping a method, __get__ is called to bind it.
# Create a wrapper for each instance and store it, so that each instance's
# method gets its share of time.
def __get__(self, inst, cls):
if inst is None:
return self

if inst not in self.__bound_methods:
# __bound_methods caches bound methods per instance. This is not
# done for perfoamnce. Bound methods can be rebound, even to
# different instances or even classes, e.g.
# >>> x = f.__get__(a, A)
# >>> y = x.__get__(b, B)
# >>> z = x.__get__(a, A)
# After this, we want `x is z`, there shared caching. This looks
# bizarre, but let's keep it safe. At least binding to the same
# instance, f.__get__(a, A),__get__(a, A), sounds reasonably
# possible.
cls = type(self)
bound_overflow = self.overflow and self.overflow.__get__(inst, cls)
decorator = cls(
self.allotted_time,
overflow=bound_overflow,
_bound_methods=self.__bound_methods)
self.__bound_methods[inst] = decorator(self.func.__get__(inst, cls))

return self.__bound_methods[inst]

def __call__(self, *args, **kwargs):
if time.perf_counter() < self.no_call_before:
if self.overflow is None:
return None
return self.overflow(*args, **kwargs)
return self.call(*args, **kwargs)

def call(self, *args, **kwargs):
start = time.perf_counter()
result = self.func(*args, **kwargs)
self.last_call_duration = time.perf_counter() - start
if self.allotted_time is not None:
if self.overflow is None:
assert result is None, "skippable function cannot return a result"
self.no_call_before = start + self.last_call_duration / self.allotted_time
return result


def run(positions, edges, observe_weights, init_temp, k, state):
def update(positions, progress):
state.set_progress_value(progress)
Expand Down Expand Up @@ -649,6 +787,7 @@ def on_done(self, positions): # pylint: disable=arguments-renamed
self.graph.Simplifications.NoSimplifications)
self.graph.update_coordinates()

@allot(0.02)
def on_partial_result(self, positions): # pylint: disable=arguments-renamed
self.positions = positions
self.graph.update_coordinates()
Expand Down Expand Up @@ -688,9 +827,12 @@ def main():
from os.path import join, dirname

network = read_pajek(join(dirname(dirname(__file__)), 'networks', 'leu_by_genesets.net'))
# network = read_pajek(
# join(dirname(dirname(__file__)), 'networks', 'dicty_publication.net'))
#network = read_pajek(join(dirname(dirname(__file__)), 'networks', 'davis.net'))
#transform_data_to_orange_table(network)
WidgetPreview(OWNxExplorer).run(set_graph=network)


if __name__ == "__main__":
main()
9 changes: 8 additions & 1 deletion orangecontrib/network/widgets/tests/test_OWNxExplorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@

import numpy as np

from orangecontrib.network.widgets.tests.utils import NetworkTest
import Orange
from orangewidget.tests.utils import simulate

from orangecontrib.network.widgets.tests.utils import NetworkTest
from orangecontrib.network import Network
from orangecontrib.network.widgets.OWNxExplorer import OWNxExplorer

Expand All @@ -23,6 +24,12 @@ def test_minimum_size(self):
# Disable this test from the base test class
pass

@unittest.skipIf(Orange.__version__ < "3.38", "3.36 is not released yet")
def test_remove_allot(self):
self.fail(
"If https://github.com/biolab/orange3/pull/6612 is merged and released, "
"import allot from Orange.util and remove the class from the add-on.")


class TestOWNxExplorerWithLayout(TestOWNxExplorer):
def test_empty_network(self):
Expand Down

0 comments on commit bc4cfa8

Please sign in to comment.