From 23d43b66acbb8e8ef47b65a3055b14373129e3bb Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 17 Mar 2023 23:18:06 -0400 Subject: [PATCH 01/16] add expression constraint --- motile/_types.py | 4 +- motile/constraints/__init__.py | 2 + motile/constraints/expression.py | 82 ++++++++++++++++++++++++++++++++ motile/constraints/pin.py | 54 ++------------------- tests/test_constraints.py | 23 ++++++++- 5 files changed, 111 insertions(+), 54 deletions(-) create mode 100644 motile/constraints/expression.py diff --git a/motile/_types.py b/motile/_types.py index 944d140..01ca3b4 100644 --- a/motile/_types.py +++ b/motile/_types.py @@ -1,13 +1,13 @@ from __future__ import annotations -from typing import Any, Hashable, TypeAlias, Union +from typing import Any, TypeAlias, Union # Nodes are represented as integers, or a "meta-node" tuple of integers. NodeId: TypeAlias = Union[int, tuple[int, ...]] # objects in the graph are represented as dicts # eg. { "id": 1, "x": 0.5, "y": 0.5, "t": 0 } -GraphObject: TypeAlias = dict[Hashable, Any] +GraphObject: TypeAlias = dict[str, Any] # Edges are represented as tuples of NodeId. # (0, 1) is an edge from node 0 to node 1. diff --git a/motile/constraints/__init__.py b/motile/constraints/__init__.py index 59c0f53..9c2fdc1 100644 --- a/motile/constraints/__init__.py +++ b/motile/constraints/__init__.py @@ -1,4 +1,5 @@ from .constraint import Constraint +from .expression import ExpressionConstraint from .max_children import MaxChildren from .max_parents import MaxParents from .pin import Pin @@ -6,6 +7,7 @@ __all__ = [ "Constraint", + "ExpressionConstraint", "MaxChildren", "MaxParents", "Pin", diff --git a/motile/constraints/expression.py b/motile/constraints/expression.py new file mode 100644 index 0000000..d3a8290 --- /dev/null +++ b/motile/constraints/expression.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import ast +import contextlib +from typing import TYPE_CHECKING + +import ilpy + +from ..variables import EdgeSelected, NodeSelected +from .constraint import Constraint + +if TYPE_CHECKING: + from motile.solver import Solver + + +class ExpressionConstraint(Constraint): + """Enforces the selection of nodes/edges based on an expression evaluated + with the node/edge dict as a namespace. + + Args: + + expression (string): + An expression to evaluate for each node/edge. The expression must + evaluate to a boolean value. The expression can use any names of + node/edge attributes as variables. + + Example: + + If the nodes of a graph are: + cells = [ + {"id": 0, "t": 0, "color": "red", "score": 1.0}, + {"id": 1, "t": 0, "color": "green", "score": 1.0}, + {"id": 2, "t": 1, "color": "blue", "score": 1.0}, + ] + + Then the following constraint will select node 0: + >>> expr = "t == 0 and color != 'green'" + >>> solver.add_constraints(ExpressionConstraint(expr)) + """ + + def __init__( + self, expression: str, eval_nodes: bool = True, eval_edges: bool = True + ) -> None: + try: + tree = ast.parse(expression, mode="eval") + if not isinstance(tree, ast.Expression): + raise SyntaxError + except SyntaxError: + raise ValueError(f"Invalid expression: {expression}") from None + + self.expression = expression + self.eval_nodes = eval_nodes + self.eval_edges = eval_edges + + def instantiate(self, solver: Solver) -> list[ilpy.LinearConstraint]: + node_indicators = solver.get_variables(NodeSelected) + edge_indicators = solver.get_variables(EdgeSelected) + + select = ilpy.LinearConstraint() + exclude = ilpy.LinearConstraint() + n_selected = 0 + + for do_evaluate, graph_part, indicators in [ + (self.eval_nodes, solver.graph.nodes, node_indicators), + (self.eval_edges, solver.graph.edges, edge_indicators), + ]: + if do_evaluate: + for id_, namespace in graph_part.items(): # type: ignore + with contextlib.suppress(NameError): + if eval(self.expression, None, namespace): + select.set_coefficient(indicators[id_], 1) # type: ignore + n_selected += 1 + else: + exclude.set_coefficient(indicators[id_], 1) # type: ignore + + select.set_relation(ilpy.Relation.Equal) + select.set_value(n_selected) + + exclude.set_relation(ilpy.Relation.Equal) + exclude.set_value(0) + + return [select, exclude] diff --git a/motile/constraints/pin.py b/motile/constraints/pin.py index abc6f1b..bc86074 100644 --- a/motile/constraints/pin.py +++ b/motile/constraints/pin.py @@ -1,17 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from .expression import ExpressionConstraint -import ilpy -from ..variables import EdgeSelected, NodeSelected -from .constraint import Constraint - -if TYPE_CHECKING: - from motile.solver import Solver - - -class Pin(Constraint): +class Pin(ExpressionConstraint): """Enforces the selection of certain nodes and edges based on the value of a given attribute. @@ -31,44 +23,4 @@ class Pin(Constraint): """ def __init__(self, attribute: str) -> None: - self.attribute = attribute - - def instantiate(self, solver: Solver) -> list[ilpy.LinearConstraint]: - node_indicators = solver.get_variables(NodeSelected) - edge_indicators = solver.get_variables(EdgeSelected) - - must_select = [ - node_indicators[node] - for node, attributes in solver.graph.nodes.items() - if self.attribute in attributes and attributes[self.attribute] - ] + [ - edge_indicators[(u, v)] - for (u, v), attributes in solver.graph.edges.items() - if self.attribute in attributes and attributes[self.attribute] - ] - - must_not_select = [ - node_indicators[node] - for node, attributes in solver.graph.nodes.items() - if self.attribute in attributes and not attributes[self.attribute] - ] + [ - edge_indicators[(u, v)] - for (u, v), attributes in solver.graph.edges.items() - if self.attribute in attributes and not attributes[self.attribute] - ] - - must_select_constraint = ilpy.LinearConstraint() - must_not_select_constraint = ilpy.LinearConstraint() - - for index in must_select: - must_select_constraint.set_coefficient(index, 1) - for index in must_not_select: - must_not_select_constraint.set_coefficient(index, 1) - - must_select_constraint.set_relation(ilpy.Relation.Equal) - must_not_select_constraint.set_relation(ilpy.Relation.Equal) - - must_select_constraint.set_value(len(must_select)) - must_not_select_constraint.set_value(0) - - return [must_select_constraint, must_not_select_constraint] + super().__init__(f"{attribute} == True", eval_nodes=True, eval_edges=True) diff --git a/tests/test_constraints.py b/tests/test_constraints.py index 7baaf51..4ff8432 100644 --- a/tests/test_constraints.py +++ b/tests/test_constraints.py @@ -2,9 +2,10 @@ import motile from data import create_arlo_trackgraph -from motile.constraints import MaxChildren, MaxParents, Pin +from motile.constraints import ExpressionConstraint, MaxChildren, MaxParents, Pin from motile.costs import Appear, EdgeSelection, NodeSelection, Split from motile.variables import EdgeSelected +from motile.variables.node_selected import NodeSelected class TestConstraints(unittest.TestCase): @@ -34,3 +35,23 @@ def test_pin(self): assert (0, 2) not in selected_edges assert (3, 6) in selected_edges + + def test_complex_expression(self): + graph = create_arlo_trackgraph() + graph.nodes[5]["color"] = "red" + + solver = motile.Solver(graph) + solver.add_costs(NodeSelection(weight=-1.0, attribute="score", constant=-100.0)) + solver.add_costs(EdgeSelection(weight=1.0, attribute="prediction_distance")) + + # constrain solver based on attributes of nodes/edges + expr = "x > 140 and t != 1 and color != 'red'" + solver.add_constraints(ExpressionConstraint(expr)) + + solution = solver.solve() + node_indicators = solver.get_variables(NodeSelected) + selected_nodes = [ + node for node, index in node_indicators.items() if solution[index] > 0.5 + ] + + assert selected_nodes == [1, 6] From 92058a2cd32feed697797b0282c12fdc5b863c63 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 17 Mar 2023 23:20:05 -0400 Subject: [PATCH 02/16] doc --- motile/constraints/expression.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/motile/constraints/expression.py b/motile/constraints/expression.py index d3a8290..60b2dcd 100644 --- a/motile/constraints/expression.py +++ b/motile/constraints/expression.py @@ -23,6 +23,10 @@ class ExpressionConstraint(Constraint): An expression to evaluate for each node/edge. The expression must evaluate to a boolean value. The expression can use any names of node/edge attributes as variables. + eval_nodes (bool): + Whether to evaluate the expression for nodes. By default, True. + eval_edges (bool): + Whether to evaluate the expression for edges. By default, True. Example: From acdda008b4699fa511d9be1b27a1ee423e821bf7 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 29 Mar 2023 15:35:54 -0400 Subject: [PATCH 03/16] docs: enable deploy to github-pages --- .github/workflows/publish-docs.yaml | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish-docs.yaml b/.github/workflows/publish-docs.yaml index c5b9b3b..45229c3 100644 --- a/.github/workflows/publish-docs.yaml +++ b/.github/workflows/publish-docs.yaml @@ -1,4 +1,4 @@ -name: Deploy to GitHub Pages +name: Deploy Docs to GitHub Pages on: push: @@ -42,13 +42,15 @@ jobs: path: docs/build/html retention-days: 90 - # deploy: - # needs: build - # runs-on: ubuntu-latest - # environment: - # name: github-pages - # url: ${{ steps.deployment.outputs.page_url }} - # steps: - # - name: Deploy to GitHub Pages - # id: deployment - # uses: actions/deploy-pages@v1 + deploy: + needs: build + # Deploy to the github-pages environment + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + runs-on: ubuntu-latest + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v1 From 04de980e4674abec051d67f24ac9cc52ff9fbfff Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 29 Mar 2023 15:42:40 -0400 Subject: [PATCH 04/16] also build on labeled PR --- .github/workflows/publish-docs.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/publish-docs.yaml b/.github/workflows/publish-docs.yaml index 45229c3..de0207c 100644 --- a/.github/workflows/publish-docs.yaml +++ b/.github/workflows/publish-docs.yaml @@ -4,6 +4,8 @@ on: push: branches: [main] workflow_dispatch: + pull_request: + types: [ labeled ] # Allow this job to clone the repo and create a page deployment permissions: @@ -13,6 +15,7 @@ permissions: jobs: build: + if: contains(github.event.pull_request.labels.*.name, 'docs') || github.event_name != 'pull_request' runs-on: ubuntu-latest defaults: run: @@ -43,6 +46,7 @@ jobs: retention-days: 90 deploy: + if: github.ref == 'refs/heads/main' needs: build # Deploy to the github-pages environment environment: From d92d6739c7ac6e68d83a4e612b54c18a955ff5e4 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 29 Mar 2023 15:43:33 -0400 Subject: [PATCH 05/16] change trigger label --- .github/workflows/publish-docs.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-docs.yaml b/.github/workflows/publish-docs.yaml index de0207c..3f0d966 100644 --- a/.github/workflows/publish-docs.yaml +++ b/.github/workflows/publish-docs.yaml @@ -5,7 +5,7 @@ on: branches: [main] workflow_dispatch: pull_request: - types: [ labeled ] + types: [labeled] # Allow this job to clone the repo and create a page deployment permissions: @@ -15,7 +15,7 @@ permissions: jobs: build: - if: contains(github.event.pull_request.labels.*.name, 'docs') || github.event_name != 'pull_request' + if: contains(github.event.pull_request.labels.*.name, 'documentation') || github.event_name != 'pull_request' runs-on: ubuntu-latest defaults: run: From acc422d0921bc2c41c3a57811c7b0fcb7119792c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 29 Mar 2023 15:44:40 -0400 Subject: [PATCH 06/16] add comment --- .github/workflows/publish-docs.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/publish-docs.yaml b/.github/workflows/publish-docs.yaml index 3f0d966..def4e5d 100644 --- a/.github/workflows/publish-docs.yaml +++ b/.github/workflows/publish-docs.yaml @@ -5,6 +5,8 @@ on: branches: [main] workflow_dispatch: pull_request: + # you can trigger the docs build on a PR by adding the label 'documentation' + # it will still only deploy on the main branch types: [labeled] # Allow this job to clone the repo and create a page deployment From b944c5341f591c569dc8d4d0e21a3e6f1ed4652f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 29 Mar 2023 16:58:25 -0400 Subject: [PATCH 07/16] try on macos --- .github/workflows/publish-docs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-docs.yaml b/.github/workflows/publish-docs.yaml index def4e5d..11cd0c5 100644 --- a/.github/workflows/publish-docs.yaml +++ b/.github/workflows/publish-docs.yaml @@ -18,7 +18,7 @@ permissions: jobs: build: if: contains(github.event.pull_request.labels.*.name, 'documentation') || github.event_name != 'pull_request' - runs-on: ubuntu-latest + runs-on: macos-latest defaults: run: shell: bash -l {0} From f503438f4903d58b03161d66a5b9cb640f8733ee Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 6 May 2023 11:50:18 -0400 Subject: [PATCH 08/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fd2b425..c547196 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Read all about it in the [documentation](https://funkelab.github.io/motile/). ## Installation -Motile depends on `ilpy`, which is currently only available via +Motile depends on [`ilpy`](https://github.com/funkelab/ilpy), which is currently only available via conda on the `funkelab` channel. `ilpy` in turn requires gurobi which is only available via the `gurobi` channel. From 2495bb75baa968ee7058211df577e6f620606417 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 6 May 2023 13:28:13 -0400 Subject: [PATCH 09/16] docs: suppress structsvm warning on docs build (#44) --- docs/source/learning.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/source/learning.rst b/docs/source/learning.rst index 75c279c..8dcbcb5 100644 --- a/docs/source/learning.rst +++ b/docs/source/learning.rst @@ -228,6 +228,13 @@ Learn Weights Learning the weights is done by calling :func:`motile.Solver.fit_weights` on the ground-truth attribute ``gt`` that we just added: +.. jupyter-execute:: + :hide-code: + + # this suppresses logging output from structsvm that can fail the docs build + import logging + logging.getLogger("structsvm.bundle_method").setLevel(logging.CRITICAL) + .. jupyter-execute:: :hide-output: From 348313ef7abb2b87259c15c93955d0d69220edae Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 6 May 2023 16:04:15 -0400 Subject: [PATCH 10/16] back to ubuntu --- .github/workflows/publish-docs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-docs.yaml b/.github/workflows/publish-docs.yaml index 11cd0c5..def4e5d 100644 --- a/.github/workflows/publish-docs.yaml +++ b/.github/workflows/publish-docs.yaml @@ -18,7 +18,7 @@ permissions: jobs: build: if: contains(github.event.pull_request.labels.*.name, 'documentation') || github.event_name != 'pull_request' - runs-on: macos-latest + runs-on: ubuntu-latest defaults: run: shell: bash -l {0} From b8f207e9a7a8a500cef0862f1b8714288287601b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 6 May 2023 16:08:13 -0400 Subject: [PATCH 11/16] fix lint --- motile/plot.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/motile/plot.py b/motile/plot.py index 9053b35..490710c 100644 --- a/motile/plot.py +++ b/motile/plot.py @@ -99,7 +99,7 @@ def draw_track_graph( if position_func is None: def position_func(node: NodeId) -> float: - return float(graph.nodes[node][position_attribute]) + return float(graph.nodes[node][position_attribute]) # type: ignore alpha_node_func: ReturnsFloat alpha_edge_func: ReturnsFloat @@ -109,10 +109,10 @@ def position_func(node: NodeId) -> float: if alpha_attribute is not None: def alpha_node_func(node): - return graph.nodes[node].get(alpha_attribute, 1.0) + return graph.nodes[node].get(alpha_attribute, 1.0) # type: ignore def alpha_edge_func(edge): - return graph.edges[edge].get(alpha_attribute, 1.0) + return graph.edges[edge].get(alpha_attribute, 1.0) # type: ignore elif alpha_func is None: @@ -131,10 +131,10 @@ def alpha_edge_func(_): if label_attribute is not None: def label_node_func(node): - return graph.nodes[node].get(label_attribute, "") + return graph.nodes[node].get(label_attribute, "") # type: ignore def label_edge_func(edge): - return graph.edges[edge].get(label_attribute, "") + return graph.edges[edge].get(label_attribute, "") # type: ignore elif label_func is None: From 41514bed3d674375d666e6072b41c4e4dbb8c809 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 8 May 2023 09:06:37 -0400 Subject: [PATCH 12/16] build all the time --- .github/workflows/publish-docs.yaml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-docs.yaml b/.github/workflows/publish-docs.yaml index def4e5d..1c1b90a 100644 --- a/.github/workflows/publish-docs.yaml +++ b/.github/workflows/publish-docs.yaml @@ -3,11 +3,9 @@ name: Deploy Docs to GitHub Pages on: push: branches: [main] - workflow_dispatch: pull_request: - # you can trigger the docs build on a PR by adding the label 'documentation' - # it will still only deploy on the main branch - types: [labeled] + branches: [main] + workflow_dispatch: # Allow this job to clone the repo and create a page deployment permissions: @@ -17,7 +15,6 @@ permissions: jobs: build: - if: contains(github.event.pull_request.labels.*.name, 'documentation') || github.event_name != 'pull_request' runs-on: ubuntu-latest defaults: run: From c1ea0900c3baee9cb4dc44541975d9e27c916ce2 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 8 May 2023 09:11:36 -0400 Subject: [PATCH 13/16] add dependabot config to auto-update CI dependencies --- .github/dependabot.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..96505a9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "ci(dependabot):" From dfce67b3ee73f989b8e1f78d4954fcfe27e9b511 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 11 May 2023 16:38:12 -0400 Subject: [PATCH 14/16] update comments --- motile/_types.py | 4 +-- motile/constraints/expression.py | 52 ++++++++++++++++++++------------ motile/plot.py | 4 +-- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/motile/_types.py b/motile/_types.py index 01ca3b4..c75db1d 100644 --- a/motile/_types.py +++ b/motile/_types.py @@ -1,13 +1,13 @@ from __future__ import annotations -from typing import Any, TypeAlias, Union +from typing import Any, Mapping, TypeAlias, Union # Nodes are represented as integers, or a "meta-node" tuple of integers. NodeId: TypeAlias = Union[int, tuple[int, ...]] # objects in the graph are represented as dicts # eg. { "id": 1, "x": 0.5, "y": 0.5, "t": 0 } -GraphObject: TypeAlias = dict[str, Any] +GraphObject: TypeAlias = Mapping[str, Any] # Edges are represented as tuples of NodeId. # (0, 1) is an edge from node 0 to node 1. diff --git a/motile/constraints/expression.py b/motile/constraints/expression.py index 6c12f5e..ea9a94d 100644 --- a/motile/constraints/expression.py +++ b/motile/constraints/expression.py @@ -2,16 +2,19 @@ import ast import contextlib -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union import ilpy -from ..variables import EdgeSelected, NodeSelected +from ..variables import EdgeSelected, NodeSelected, Variable from .constraint import Constraint if TYPE_CHECKING: + from motile._types import EdgeId, GraphObject, NodeId from motile.solver import Solver + NodesOrEdges = Union[dict[NodeId, GraphObject], dict[EdgeId, GraphObject]] + class ExpressionConstraint(Constraint): """Enforces the selection of nodes/edges based on an expression evaluated @@ -57,26 +60,35 @@ def __init__( self.eval_edges = eval_edges def instantiate(self, solver: Solver) -> list[ilpy.Constraint]: - node_indicators = solver.get_variables(NodeSelected) - edge_indicators = solver.get_variables(EdgeSelected) - + # create two constraints: one to select nodes/edges, and one to exclude select = ilpy.Constraint() exclude = ilpy.Constraint() - n_selected = 0 - - for do_evaluate, graph_part, indicators in [ - (self.eval_nodes, solver.graph.nodes, node_indicators), - (self.eval_edges, solver.graph.edges, edge_indicators), - ]: - if do_evaluate: - for id_, namespace in graph_part.items(): # type: ignore - with contextlib.suppress(NameError): - if eval(self.expression, None, namespace): - select.set_coefficient(indicators[id_], 1) # type: ignore - n_selected += 1 - else: - exclude.set_coefficient(indicators[id_], 1) # type: ignore - + n_selected = 0 # number of nodes/edges selected + + to_evaluate: list[tuple[NodesOrEdges, type[Variable]]] = [] + if self.eval_nodes: + to_evaluate.append((solver.graph.nodes, NodeSelected)) + if self.eval_edges: + to_evaluate.append((solver.graph.edges, EdgeSelected)) + + for nodes_or_edges, VariableType in to_evaluate: + indicator_variables = solver.get_variables(VariableType) + for id_, node_or_edge in nodes_or_edges.items(): + with contextlib.suppress(NameError): + # Here is where the expression string is evaluated. + # We use the node/edge dict as a namespace to look up variables. + # if the expression uses a variable name that is not in the dict, + # a NameError will be raised. + # contextlib.suppress (above) will just skip it and move on... + if eval(self.expression, None, node_or_edge): + # if the expression evaluates to True, we select the node/edge + select.set_coefficient(indicator_variables[id_], 1) + n_selected += 1 + else: + # Otherwise, we exclude it. + exclude.set_coefficient(indicator_variables[id_], 1) + + # finally, apply the relation and value to the constraints select.set_relation(ilpy.Relation.Equal) select.set_value(n_selected) diff --git a/motile/plot.py b/motile/plot.py index 490710c..6b905d3 100644 --- a/motile/plot.py +++ b/motile/plot.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable, overload +from typing import TYPE_CHECKING, Any, Callable, Mapping, overload import numpy as np @@ -24,7 +24,7 @@ PURPLE = (127, 30, 121) -def _attr_hover_text(attrs: dict) -> str: +def _attr_hover_text(attrs: Mapping) -> str: return "
".join([f"{name}: {value}" for name, value in attrs.items()]) From 6a8da3ff77ed7c6deaa24ddd9a80c27e2317236f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 11 May 2023 16:45:02 -0400 Subject: [PATCH 15/16] more docs --- motile/constraints/expression.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/motile/constraints/expression.py b/motile/constraints/expression.py index ea9a94d..6b0d002 100644 --- a/motile/constraints/expression.py +++ b/motile/constraints/expression.py @@ -20,8 +20,23 @@ class ExpressionConstraint(Constraint): """Enforces the selection of nodes/edges based on an expression evaluated with the node/edge dict as a namespace. - Args: + This is a powerful general constraint that allows you to select nodes/edges based on + any combination of node/edge attributes. The `expression` string is evaluated for + each node/edge (assuming eval_nodes/eval_edges is True) using the actual node object + as a namespace to populate any variables names used in the provided expression. If + the expression evaluates to True, the node/edge is selected; otherwise, it is + excluded. + + This takes advantaged of python's `eval` function, like this: + + ```python + my_expression = "some_attribute == True" + eval(my_expression, None, {"some_attribute": True}) # returns True (select) + eval(my_expression, None, {"some_attribute": False}) # returns False (exclude) + eval(my_expression, None, {}) # raises NameError (do nothing) + ``` + Args: expression (string): An expression to evaluate for each node/edge. The expression must evaluate to a boolean value. The expression can use any names of From feec08dd4fdc4e67b3e36dca5f8a69ec1010b6a1 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 11 May 2023 16:52:12 -0400 Subject: [PATCH 16/16] pre-compile expression --- motile/constraints/expression.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/motile/constraints/expression.py b/motile/constraints/expression.py index 6b0d002..9b7aeb3 100644 --- a/motile/constraints/expression.py +++ b/motile/constraints/expression.py @@ -70,7 +70,7 @@ def __init__( except SyntaxError: raise ValueError(f"Invalid expression: {expression}") from None - self.expression = expression + self._expression = compile(expression, "", "eval") self.eval_nodes = eval_nodes self.eval_edges = eval_edges @@ -95,7 +95,7 @@ def instantiate(self, solver: Solver) -> list[ilpy.Constraint]: # if the expression uses a variable name that is not in the dict, # a NameError will be raised. # contextlib.suppress (above) will just skip it and move on... - if eval(self.expression, None, node_or_edge): + if eval(self._expression, None, node_or_edge): # if the expression evaluates to True, we select the node/edge select.set_coefficient(indicator_variables[id_], 1) n_selected += 1