From f161c3c2e50aa0e835d6f7a8615e6a07b13ae10e Mon Sep 17 00:00:00 2001 From: Philipp Schlegel Date: Sun, 22 Jan 2023 15:25:40 +0000 Subject: [PATCH 01/15] docs: try fixing RTD build --- docs/source/python2cytoscape.ipynb | 23 +++-------------------- docs/source/whats_new.rst | 2 +- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/docs/source/python2cytoscape.ipynb b/docs/source/python2cytoscape.ipynb index e26e571d..1808846b 100644 --- a/docs/source/python2cytoscape.ipynb +++ b/docs/source/python2cytoscape.ipynb @@ -745,7 +745,7 @@ "Updating networks\n", "+++++++++++++++++\n", "\n", - "Another powerful usage of the cyREST API is to have scripts running in the background to automatically update, layout or style networks. Pymaid's :func:`~pymaid.cytoscape.watch_network` lets you constantly update a defined network:\n", + "Another powerful usage of the cyREST API is to have scripts running in the background to automatically update, layout or style networks. Pymaid's ``pymaid.cytoscape.watch_network`` lets you constantly update a defined network:\n", "\n", "In this example, we are watching a set of *\"seed\"* neurons and their direct downstream partners. " ] @@ -774,7 +774,7 @@ "source": [ "This function runs in an infinite loop. In order to interupt it, simply press ``CTRL-C`` if you are in terminal. In a Jupyter notebook, hit the **stop** button in the top toolbar.\n", "\n", - "Similar to the the earlier example, :func:`~pymaid.cytoscape.watch_network` also allows to collapse neurons into groups:" + "Similar to the the earlier example, ``pymaid.cytoscape.watch_network`` also allows to collapse neurons into groups:" ] }, { @@ -792,25 +792,8 @@ " group_by={'exc DA1': pymaid.get_skids_by_annotation('glomerulus DA1 right excitatory')}\n", " )" ] - }, - { - "metadata": { - "raw_mimetype": "text/restructuredtext" - }, - "cell_type": "raw", - "source": [ - "Pymaid-Cytoscape wrappers\n", - "=========================\n", - "\n", - ".. autosummary::\n", - " :toctree: generated/\n", - "\n", - " pymaid.cytoscape.generate_network\n", - " pymaid.cytoscape.get_client\n", - " pymaid.cytoscape.watch_network" - ] } ], "nbformat_minor": 2, "nbformat": 4 -} \ No newline at end of file +} diff --git a/docs/source/whats_new.rst b/docs/source/whats_new.rst index 78dcdfb1..aebf3f90 100644 --- a/docs/source/whats_new.rst +++ b/docs/source/whats_new.rst @@ -115,7 +115,7 @@ What's new? - various bugfixes * - 0.89 - 14/08/18 - - - new function: :func:`~pymaid.cytoscape.watch_network` constantly pushes updates Cytoscape + - - new function: ``pymaid.cytoscape.watch_network`` constantly pushes updates to Cytoscape - new function: :func:`~pymaid.get_nth_partners` returns neurons connected via n hops - by default, :func:`~pymaid.plot3d` now chooses the backend automatically: vispy for terminal sessions, plotly for Jupyter notebook/lab - :func:`~pymaid.get_skids_by_annotation` now accepts negative search criteria From 58907495b7d394b027a0575183ccfef2dc2f2e51 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Fri, 17 Mar 2023 15:33:55 +0000 Subject: [PATCH 02/15] Allow get_annotation_graph to represent all types N.B. now returns neurons by default, rather than skeletons, for consistency. This is a breaking change. --- docs/source/whats_new.rst | 3 + pymaid/fetch/__init__.py | 94 +------------- pymaid/fetch/annotations.py | 245 ++++++++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 93 deletions(-) create mode 100644 pymaid/fetch/annotations.py diff --git a/docs/source/whats_new.rst b/docs/source/whats_new.rst index aebf3f90..56b6f197 100644 --- a/docs/source/whats_new.rst +++ b/docs/source/whats_new.rst @@ -10,6 +10,9 @@ What's new? * - Version - Date - + * - Next + - In progress + - - signature of :func:`pymaid.get_annotation_graph` changed, allowing it to represent all semantic entities. * - 2.1.0 - 04/04/22 - With this release we mainly follow some renamed functions in ``navis`` but diff --git a/pymaid/fetch/__init__.py b/pymaid/fetch/__init__.py index c70e3f8d..a49f8185 100644 --- a/pymaid/fetch/__init__.py +++ b/pymaid/fetch/__init__.py @@ -56,6 +56,7 @@ from navis import in_volume from .landmarks import get_landmarks, get_landmark_groups from .skeletons import get_skeleton_ids +from .annnotations import get_annotation_graph __all__ = ['get_annotation_details', 'get_annotation_id', @@ -1901,99 +1902,6 @@ def get_annotations(x, remote_instance=None): 'No annotations retrieved. Make sure that the skeleton IDs exist.') -def _entities_to_ann_graph(data, annotations_by_id=False, skeletons_by_id=True): - ann_ref = "id" if annotations_by_id else "name" - skel_ref = "id" if skeletons_by_id else "name" - - g = nx.DiGraph() - - for e in data["entities"]: - is_meta_ann = False - - if e.get("type") == "neuron": - skids = e.get("skeleton_ids") or [] - if len(skids) != 1: - logger.warning("Neuron with id %s is modelled by %s skeletons, ignoring", e["id"], len(skids)) - continue - node_data = { - "name": e["name"], - "neuron_id": e["id"], - "is_skeleton": True, - "id": skids[0], - } - node_id = node_data[skel_ref] - else: # is an annotation - node_data = { - "is_skeleton": False, - "id": e["id"], - "name": e["name"], - } - node_id = node_data[ann_ref] - is_meta_ann = True - - anns = e.get("annotations", []) - if not anns: - g.add_node(node_id, **node_data) - continue - - for ann in e.get("annotations", []): - g.add_edge( - ann[ann_ref], - node_id, - is_meta_annotation=is_meta_ann, - ) - - g.nodes[node_id].update(**node_data) - - return g - - -@cache.undo_on_error -def get_annotation_graph(annotations_by_id=False, skeletons_by_id=True, remote_instance=None) -> nx.DiGraph: - """Get a networkx DiGraph of (meta)annotations and skeletons. - - Can be slow for large projects. - - Nodes in the graph have data: - - Skeletons have - - id - - is_skeleton = True - - neuron_id (different to the skeleton ID) - - name - - Annotations have - - id - - name - - is_skeleton = False - - Edges in the graph have - - is_meta_annotation (whether it is between two annotations) - - Parameters - ---------- - annotations_by_id : bool, default False - Whether to index nodes representing annotations by their integer ID - (uses name by default) - skeletons_by_id : bool, default True - whether to index nodes representing skeletons by their integer ID - (True by default, otherwise uses the neuron name) - remote_instance : optional CatmaidInstance - - Returns - ------- - networkx.DiGraph - """ - remote_instance = utils._eval_remote_instance(remote_instance) - - query_url = remote_instance.make_url(remote_instance.project_id, "annotations", "query-targets") - post = { - "with_annotations": True, - } - data = remote_instance.fetch(query_url, post) - - return _entities_to_ann_graph(data, annotations_by_id, skeletons_by_id) - def filter_by_query(names: pd.Series, query: str, allow_partial: bool = False) -> pd.Series: """Get a logical index series into a series of strings based on a query. diff --git a/pymaid/fetch/annotations.py b/pymaid/fetch/annotations.py new file mode 100644 index 00000000..0c79608e --- /dev/null +++ b/pymaid/fetch/annotations.py @@ -0,0 +1,245 @@ +from collections.abc import Sequence +from typing import ( + Optional, + Literal, + Callable, + List, + DefaultDict, + Union, + Tuple, + Dict, + Any, +) +from collections import defaultdict +from itertools import chain + +import networkx as nx + +from .. import config, cache, utils + +logger = config.get_logger(__name__) + + +class UnknownEntityTypeError(RuntimeError): + _known = {"neuron", "annotation", "volume"} + + def __init__(self, etype: str): + super().__init__( + f"Entity type {repr(etype)} unknown; should be one of {', '.join(sorted(self._known))}" + ) + + @classmethod + def raise_for_etype(cls, etype: str): + if etype not in cls._known: + raise cls(etype) + + +class AmbiguousEntityNameError(RuntimeError): + def __init__(self, name: str): + super().__init__(f"Entity has non-unique name {repr(name)}; use IDs instead") + + +def get_id_key(by_name: bool): + return "name" if by_name else "id" + + +def entities_to_ann_graph(data: dict, by_name: bool): + g = nx.DiGraph() + id_key = get_id_key(by_name) + + edges = [] + + for e in data["entities"]: + etype = e.get("type") + + UnknownEntityTypeError.raise_for_etype(etype) + + is_meta_ann = False + + ndata = { + "name": e["name"], + "id": e["id"], + "type": etype, + } + node_id = ndata[id_key] + + if etype == "neuron": + skids = e.get("skeleton_ids") or [] + ndata["skeleton_ids"] = skids + + elif etype == "annotation": + is_meta_ann = True + + if by_name and node_id in g.nodes: + raise AmbiguousEntityNameError(node_id) + + g.add_node(node_id, **ndata) + + for ann in e.get("annotations", []): + edges.append((ann[id_key], node_id, {"is_meta_annotation": is_meta_ann})) + + g.add_edges_from(edges) + + return g + + +def noop(arg): + return arg + + +def neurons_to_skeletons( + g: nx.DiGraph, + by_name: bool, + select_skeletons: Callable[[List[int]], List[int]] = noop, +): + id_key = get_id_key(by_name) + + nodes_to_replace: DefaultDict[ + Union[str, int], List[Tuple[Union[str, int], Dict[str, Any]]] + ] = defaultdict(list) + + for node_id, data in g.nodes(data=True): + if data["type"] != "neuron": + continue + + skids = data["skeleton_ids"] + if len(skids) == 0: + logger.warning("Neuron %s is modelled by 0 skeletons; skipping") + nodes_to_replace[node_id] # ensure this exists + continue + + if len(skids) > 1: + skids = select_skeletons(skids) + + if by_name and len(skids) > 1: + raise AmbiguousEntityNameError(data["name"]) + + for skid in skids: + sk_data = { + "id": skid, + "name": data["name"], + "type": "skeleton", + "neuron_id": data["id"], + } + nid = sk_data[id_key] + + nodes_to_replace[node_id].append((nid, sk_data)) + + edges_to_add = [] + for src, tgt, edata in g.in_edges(nodes_to_replace, data=True): + for new_tgt, _ in nodes_to_replace[tgt]: + edges_to_add.append((src, new_tgt, edata)) + + g.remove_nodes_from(nodes_to_replace) + g.add_nodes_from(chain.from_iterable(nodes_to_replace.values())) + g.add_edges_from(edges_to_add) + + return g + + +# todo: replace with strenum +EntityType = Literal["neuron", "annotation", "volume", "skeleton"] + + +@cache.undo_on_error +def _get_entities(entity_types, remote_instance): + remote_instance = utils._eval_remote_instance(remote_instance) + post = { + "with_annotations": True, + } + if entity_types is not None: + post["types"] = list(entity_types) + + query_url = remote_instance.make_url( + remote_instance.project_id, "annotations", "query-targets" + ) + return remote_instance.fetch(query_url, post) + + +def get_annotation_graph( + types: Optional[Sequence[EntityType]] = None, by_name=False, remote_instance=None +) -> nx.DiGraph: + """Get a networkx DiGraph of semantic objects. + + Can be slow for large projects. + + Note that CATMAID distinguishes between neurons + (semantic objects which can be named and annotated) + and skeletons (spatial objects which can model neurons). + Most pymaid (and CATMAID) functions use the skeleton ID, + rather than the neuron ID, + and assume that a neuron is modeled by a single skeleton. + To replace neurons in the graph with the skeletons they are modelled by, + include ``"skeleton"`` in the ``types`` argument + (this is mutually exclusive with ``"neuron"``). + + Nodes in the graph have data: + + - id: int + - name: str + - type: str, one of "neuron", "annotation", "volume", "skeleton" + + Neurons additionally have + + - skeleton_ids: list[int] + + Skeletons additionally have + + - neuron_id: int + + Edges in the graph have + + - is_meta_annotation (bool): whether it is between two annotations + + Parameters + ---------- + types : optional sequence of str, default None + Which types of entity to fetch. + Choices are "neuron", "annotation", "volume", "skeleton"; + "neuron" and "skeleton" are mutually exclusive. + None uses CATMAID default ("neuron", "annotation"). + by_name : bool, default False + If True, use the entity's name rather than its integer ID. + This can be convenient but has a risk of name collisions, + which will raise errors. + In particular, name collisions will occur if ``types`` includes ``"skeleton"`` + and a neuron is modelled by more than one skeleton. + remote_instance : optional CatmaidInstance + + Returns + ------- + networkx.DiGraph + + Raises + ------ + UnknownEntityTypeError + CATMAID returned an entity type pymaid doesn't know how to interpret. + AmbiguousEntityNameError + When ``by_name=True`` is used, and there are naming collisions. + """ + + use_skeletons = False + + if types is None: + etypes = None + else: + etypes = set(types) + if "skeleton" in etypes: + if "neuron" in etypes: + raise ValueError("'skeleton' and 'neuron' types are mutually exclusive") + + etypes.add("neuron") + etypes.remove("skeleton") + use_skeletons = True + + if not etypes: + return nx.DiGraph() + + data = _get_entities(etypes, remote_instance) + + g = entities_to_ann_graph(data, by_name) + + if use_skeletons: + g = neurons_to_skeletons(g, by_name) + + return g From ec1a76855fae7855cb46ae9950ef1b735673b46a Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Fri, 17 Mar 2023 18:14:33 +0000 Subject: [PATCH 03/15] Finish annotation graph --- pymaid/fetch/__init__.py | 2 +- pymaid/fetch/annotations.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pymaid/fetch/__init__.py b/pymaid/fetch/__init__.py index a49f8185..a6451d5f 100644 --- a/pymaid/fetch/__init__.py +++ b/pymaid/fetch/__init__.py @@ -56,7 +56,7 @@ from navis import in_volume from .landmarks import get_landmarks, get_landmark_groups from .skeletons import get_skeleton_ids -from .annnotations import get_annotation_graph +from .annotations import get_annotation_graph __all__ = ['get_annotation_details', 'get_annotation_id', diff --git a/pymaid/fetch/annotations.py b/pymaid/fetch/annotations.py index 0c79608e..3ed8a212 100644 --- a/pymaid/fetch/annotations.py +++ b/pymaid/fetch/annotations.py @@ -104,7 +104,7 @@ def neurons_to_skeletons( skids = data["skeleton_ids"] if len(skids) == 0: - logger.warning("Neuron %s is modelled by 0 skeletons; skipping") + logger.warning("Neuron %s is modelled by 0 skeletons; skipping", data["id"]) nodes_to_replace[node_id] # ensure this exists continue @@ -143,6 +143,8 @@ def neurons_to_skeletons( @cache.undo_on_error def _get_entities(entity_types, remote_instance): + logger.info("Fetching entity graph; may be slow") + remote_instance = utils._eval_remote_instance(remote_instance) post = { "with_annotations": True, From e97f6c2625e284086872161af39a3cec94545898 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Fri, 17 Mar 2023 20:29:26 +0000 Subject: [PATCH 04/15] get_entity_graph: restrict by annotation --- pymaid/fetch/__init__.py | 1 - pymaid/fetch/annotations.py | 227 ++++++++++++++++++++++++++++++++++-- 2 files changed, 217 insertions(+), 11 deletions(-) diff --git a/pymaid/fetch/__init__.py b/pymaid/fetch/__init__.py index a6451d5f..5029cb89 100644 --- a/pymaid/fetch/__init__.py +++ b/pymaid/fetch/__init__.py @@ -1902,7 +1902,6 @@ def get_annotations(x, remote_instance=None): 'No annotations retrieved. Make sure that the skeleton IDs exist.') - def filter_by_query(names: pd.Series, query: str, allow_partial: bool = False) -> pd.Series: """Get a logical index series into a series of strings based on a query. diff --git a/pymaid/fetch/annotations.py b/pymaid/fetch/annotations.py index 3ed8a212..9d9b8251 100644 --- a/pymaid/fetch/annotations.py +++ b/pymaid/fetch/annotations.py @@ -1,4 +1,4 @@ -from collections.abc import Sequence +from collections.abc import Iterable from typing import ( Optional, Literal, @@ -12,10 +12,12 @@ ) from collections import defaultdict from itertools import chain +import warnings import networkx as nx from .. import config, cache, utils +from . import get_annotation_id logger = config.get_logger(__name__) @@ -141,16 +143,39 @@ def neurons_to_skeletons( EntityType = Literal["neuron", "annotation", "volume", "skeleton"] +def join_ids(ids: Iterable[int]) -> str: + return ",".join(str(n) for n in ids) + + +def join_id_sets(id_sets: Iterable[Iterable[int]]) -> List[str]: + return [join_ids(ids) for ids in id_sets] + + @cache.undo_on_error -def _get_entities(entity_types, remote_instance): +def _get_entities( + types: Optional[Iterable[str]] = None, + with_annotations: Optional[bool] = None, + annotated_with: Optional[Iterable[Iterable[int]]] = None, + not_annotated_with: Optional[Iterable[Iterable[int]]] = None, + sub_annotated_with: Optional[Iterable[int]] = None, + *, + remote_instance=None, +): logger.info("Fetching entity graph; may be slow") remote_instance = utils._eval_remote_instance(remote_instance) - post = { - "with_annotations": True, - } - if entity_types is not None: - post["types"] = list(entity_types) + post: Dict[str, Any] = dict() + + if types is not None: + post["types"] = list(types) + if with_annotations is not None: + post["with_annotations"] = bool(with_annotations) + if annotated_with is not None: + post["annotated_with"] = join_id_sets(annotated_with) + if not_annotated_with is not None: + post["not_annotated_with"] = join_id_sets(not_annotated_with) + if sub_annotated_with is not None: + post["sub_annotated_with"] = join_ids(sub_annotated_with) query_url = remote_instance.make_url( remote_instance.project_id, "annotations", "query-targets" @@ -158,8 +183,55 @@ def _get_entities(entity_types, remote_instance): return remote_instance.fetch(query_url, post) -def get_annotation_graph( - types: Optional[Sequence[EntityType]] = None, by_name=False, remote_instance=None +def to_nested_and_flat(objs): + if isinstance(objs, (str, bytes)) or not isinstance(objs, Iterable): + return objs, objs + + nested = [] + flattened = [] + + for item in objs: + inner_nested, inner_flattened = to_nested_and_flat(item) + nested.append(inner_nested) + flattened.extend(utils._make_iterable(inner_flattened)) + + return nested, flattened + + +def map_nested(nested, mapping): + if isinstance(nested, (str, bytes)) or not isinstance(nested, Iterable): + return mapping[nested] + + return [map_nested(item, mapping) for item in nested] + + +def _get_annotation_ids( + *ann_lols: Iterable[Iterable[Union[int, str]]], remote_instance=None +) -> List[List[List[int]]]: + nested, flattened = to_nested_and_flat(ann_lols) + id_mapping = {None: None} + names = [] + for name_or_id in flattened: + if isinstance(name_or_id, str): + names.append(name_or_id) + else: + id_mapping[name_or_id] = name_or_id + + ann_ids = get_annotation_id(names, remote_instance=remote_instance) + + id_mapping.update((n, int(aid)) for n, aid in ann_ids.items()) + + return map_nested(nested, id_mapping) + + +def get_entity_graph( + types: Optional[Iterable[EntityType]] = None, + by_name=False, + annotated_with: Optional[Iterable[Iterable[Union[int, str]]]] = None, + not_annotated_with: Optional[Iterable[Iterable[Union[int, str]]]] = None, + sub_annotated_with: Optional[Iterable[Union[int, str]]] = None, + *, + remote_instance=None, ) -> nx.DiGraph: """Get a networkx DiGraph of semantic objects. @@ -206,6 +278,19 @@ def get_annotation_graph( which will raise errors. In particular, name collisions will occur if ``types`` includes ``"skeleton"`` and a neuron is modelled by more than one skeleton. + annotated_with : Optional[Iterable[Iterable[Union[int, str]]]], default None + If not None, only include entities annotated with these annotations. + Can be integer IDs or str names (not IDs as strings!). + The inner sets are combined with OR. + The outer iterable is combined with AND. + e.g. for ``[["a", "b"], ["c"]]``, entities must be annotated with ``"c"``, + and at least one of ``"a"`` or ``"b"``, + not_annotated_with: Optional[Iterable[Iterable[Union[int, str]]]], default None + If not None, only include entites NOT annotated with these. + See ``annotated_with`` for more usage details. + sub_annotated_with: Optional[Iterable[Union[int, str]]], default None + Which annotations in the ``annotated_with``, ``not_annotated_with`` + sets to expand into all their sub-annotations (each as an OR group). remote_instance : optional CatmaidInstance Returns @@ -219,6 +304,7 @@ def get_annotation_graph( AmbiguousEntityNameError When ``by_name=True`` is used, and there are naming collisions. """ + remote_instance = utils._eval_remote_instance(remote_instance) use_skeletons = False @@ -237,7 +323,27 @@ def get_annotation_graph( if not etypes: return nx.DiGraph() - data = _get_entities(etypes, remote_instance) + ( + annotated_with_ids, + not_annotated_with_ids, + sub_annotated_with_ids, + ) = _get_annotation_ids( + annotated_with, + not_annotated_with, + sub_annotated_with, + remote_instance=remote_instance, + ) + + _, flattened_subs = to_nested_and_flat(sub_annotated_with_ids) + + data = _get_entities( + types=etypes, + with_annotations=True, + annotated_with=annotated_with_ids, + not_annotated_with=not_annotated_with_ids, + sub_annotated_with=flattened_subs, + remote_instance=remote_instance, + ) g = entities_to_ann_graph(data, by_name) @@ -245,3 +351,104 @@ def get_annotation_graph( g = neurons_to_skeletons(g, by_name) return g + + +def get_annotation_graph( + annotations_by_id=False, skeletons_by_id=True, remote_instance=None +) -> nx.DiGraph: + """DEPRECATED. Get a networkx DiGraph of (meta)annotations and skeletons. + + This function is deprecated. + Use :func:`pymaid.get_entity_graph` instead. + + Can be slow for large projects. + + Nodes in the graph have data: + + Skeletons have + + - id + - is_skeleton = True + - neuron_id (different to the skeleton ID) + - name + + Annotations have + + - id + - name + - is_skeleton = False + + Edges in the graph have + + - is_meta_annotation (whether it is between two annotations) + + Parameters + ---------- + annotations_by_id : bool, default False + Whether to index nodes representing annotations by their integer ID + (uses name by default) + skeletons_by_id : bool, default True + whether to index nodes representing skeletons by their integer ID + (True by default, otherwise uses the neuron name) + remote_instance : optional CatmaidInstance + + Returns + ------- + networkx.DiGraph + """ + warnings.warn( + DeprecationWarning("get_annotation_graph is deprecated; use get_entity_graph") + ) + + data = _get_entities( + types=None, with_annotations=True, remote_instance=remote_instance + ) + + ann_ref = "id" if annotations_by_id else "name" + skel_ref = "id" if skeletons_by_id else "name" + + g = nx.DiGraph() + + for e in data["entities"]: + is_meta_ann = False + + if e.get("type") == "neuron": + skids = e.get("skeleton_ids") or [] + if len(skids) != 1: + logger.warning( + "Neuron with id %s is modelled by %s skeletons, ignoring", + e["id"], + len(skids), + ) + continue + node_data = { + "name": e["name"], + "neuron_id": e["id"], + "is_skeleton": True, + "id": skids[0], + } + node_id = node_data[skel_ref] + else: # is an annotation + node_data = { + "is_skeleton": False, + "id": e["id"], + "name": e["name"], + } + node_id = node_data[ann_ref] + is_meta_ann = True + + anns = e.get("annotations", []) + if not anns: + g.add_node(node_id, **node_data) + continue + + for ann in e.get("annotations", []): + g.add_edge( + ann[ann_ref], + node_id, + is_meta_annotation=is_meta_ann, + ) + + g.nodes[node_id].update(**node_data) + + return g From fd6039e19145d868f54051dfc4bc955c34f0cb9c Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Fri, 17 Mar 2023 20:36:18 +0000 Subject: [PATCH 05/15] Finalise get_entity_graph --- docs/source/whats_new.rst | 3 ++- pymaid/fetch/__init__.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/source/whats_new.rst b/docs/source/whats_new.rst index 56b6f197..ba2e9d36 100644 --- a/docs/source/whats_new.rst +++ b/docs/source/whats_new.rst @@ -12,7 +12,8 @@ What's new? - * - Next - In progress - - - signature of :func:`pymaid.get_annotation_graph` changed, allowing it to represent all semantic entities. + - - :func:`pymaid.get_annotation_graph` deprecated in favour of the new + :func:`pymaid.get_entity_graph`. * - 2.1.0 - 04/04/22 - With this release we mainly follow some renamed functions in ``navis`` but diff --git a/pymaid/fetch/__init__.py b/pymaid/fetch/__init__.py index 5029cb89..ff00ecaf 100644 --- a/pymaid/fetch/__init__.py +++ b/pymaid/fetch/__init__.py @@ -56,7 +56,7 @@ from navis import in_volume from .landmarks import get_landmarks, get_landmark_groups from .skeletons import get_skeleton_ids -from .annotations import get_annotation_graph +from .annotations import get_annotation_graph, get_entity_graph __all__ = ['get_annotation_details', 'get_annotation_id', @@ -64,7 +64,8 @@ 'get_arbor', 'get_connector_details', 'get_connectors', 'get_connector_tags', - 'get_contributor_statistics', 'get_edges', 'get_history', + 'get_contributor_statistics', 'get_edges', 'get_entity_graph', + 'get_history', 'get_logs', 'get_names', 'get_neuron', 'get_neurons', 'get_neurons_in_bbox', 'get_neurons_in_volume', 'get_node_tags', 'get_node_details', From 73294f61f9f0c8aca013f933c35dba900d3b92b4 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Mon, 20 Mar 2023 13:37:50 +0000 Subject: [PATCH 06/15] Fix entity graph annotation edges --- pymaid/fetch/__init__.py | 93 +----------------------------- pymaid/fetch/annotations.py | 109 ++++++++++++++++++++++++++++++++++-- pymaid/tests/test_utils.py | 2 +- 3 files changed, 106 insertions(+), 98 deletions(-) diff --git a/pymaid/fetch/__init__.py b/pymaid/fetch/__init__.py index ff00ecaf..15f8c074 100644 --- a/pymaid/fetch/__init__.py +++ b/pymaid/fetch/__init__.py @@ -56,7 +56,7 @@ from navis import in_volume from .landmarks import get_landmarks, get_landmark_groups from .skeletons import get_skeleton_ids -from .annotations import get_annotation_graph, get_entity_graph +from .annotations import get_annotation_graph, get_entity_graph, get_annotation_id __all__ = ['get_annotation_details', 'get_annotation_id', @@ -1903,97 +1903,6 @@ def get_annotations(x, remote_instance=None): 'No annotations retrieved. Make sure that the skeleton IDs exist.') -def filter_by_query(names: pd.Series, query: str, allow_partial: bool = False) -> pd.Series: - """Get a logical index series into a series of strings based on a query. - - Parameters - ---------- - names : pd.Series of str - Dataframe column of strings to filter - query : str - Query string. leading "~" and "annotation:" will be ignored. - Leading "/" will mean the remainder is used as a regex. - allow_partial : bool, default False - For non-regex queries, whether to check that the query is an exact match or just contained in the name. - - Returns - ------- - pd.Series of bool - Which names match the given query - """ - if not isinstance(names, pd.Series): - names = pd.Series(names, dtype=str) - - for prefix in ["annotation:", "~"]: - if query.startswith(prefix): - logger.warning("Removing '%s' prefix from '%s'", prefix, query) - query = query[len(prefix):] - - q = query.strip() - # use a regex - if q.startswith("/"): - re_str = q[1:] - filt = names.str.match(re_str) - else: - filt = names.str.contains(q, regex=False) - if not allow_partial: - filt = np.logical_and(filt, names.str.len() == len(q)) - - return filt - - -@cache.wipe_and_retry -def get_annotation_id(annotations, allow_partial=False, raise_not_found=True, - remote_instance=None): - """Retrieve the annotation ID for single or list of annotation(s). - - Parameters - ---------- - annotations : str | list of str - Single annotations or list of multiple annotations. - allow_partial : bool, optional - If True, will allow partial matches. - raise_not_found : bool, optional - If True raise Exception if no match for any of the - query annotations is found. Else log warning. - remote_instance : CatmaidInstance, optional - If not passed directly, will try using global. - - Returns - ------- - dict - ``{'annotation_name': 'annotation_id', ...}`` - - """ - remote_instance = utils._eval_remote_instance(remote_instance) - - logger.debug('Retrieving list of annotations...') - - remote_annotation_list_url = remote_instance._get_annotation_list() - an_list = remote_instance.fetch(remote_annotation_list_url) - - # Turn into pandas array - an_list = pd.DataFrame.from_records(an_list['annotations']) - - annotations = utils._make_iterable(annotations) - annotation_ids = {} - for an in annotations: - filt = filter_by_query(an_list.name, an, allow_partial) - - # Search for matches - res = an_list[filt].set_index('name').id.to_dict() - if not res: - logger.warning('No annotation found for "{}"'.format(an)) - annotation_ids.update(res) - - if not annotation_ids: - if raise_not_found: - raise Exception('No matching annotation(s) found') - else: - logger.warning('No matching annotation(s) found') - - return annotation_ids - @cache.undo_on_error def find_nodes(tags=None, node_ids=None, skeleton_ids=None, diff --git a/pymaid/fetch/annotations.py b/pymaid/fetch/annotations.py index 9d9b8251..5e19ab4a 100644 --- a/pymaid/fetch/annotations.py +++ b/pymaid/fetch/annotations.py @@ -15,12 +15,110 @@ import warnings import networkx as nx +import pandas as pd +import numpy as np from .. import config, cache, utils -from . import get_annotation_id logger = config.get_logger(__name__) +__all__ = ["get_annotation_id", "get_entity_graph", "get_annotation_graph"] + + +def filter_by_query( + names: pd.Series, query: str, allow_partial: bool = False +) -> pd.Series: + """Get a logical index series into a series of strings based on a query. + + Parameters + ---------- + names : pd.Series of str + Dataframe column of strings to filter + query : str + Query string. leading "~" and "annotation:" will be ignored. + Leading "/" will mean the remainder is used as a regex. + allow_partial : bool, default False + For non-regex queries, whether to check that the query is an exact match or just contained in the name. + + Returns + ------- + pd.Series of bool + Which names match the given query + """ + if not isinstance(names, pd.Series): + names = pd.Series(names, dtype=str) + + for prefix in ["annotation:", "~"]: + if query.startswith(prefix): + logger.warning("Removing '%s' prefix from '%s'", prefix, query) + query = query[len(prefix) :] + + q = query.strip() + # use a regex + if q.startswith("/"): + re_str = q[1:] + filt = names.str.match(re_str) + else: + filt = names.str.contains(q, regex=False) + if not allow_partial: + filt = np.logical_and(filt, names.str.len() == len(q)) + + return filt + + +@cache.wipe_and_retry +def get_annotation_id( + annotations, allow_partial=False, raise_not_found=True, remote_instance=None +): + """Retrieve the annotation ID for single or list of annotation(s). + + Parameters + ---------- + annotations : str | list of str + Single annotations or list of multiple annotations. + allow_partial : bool, optional + If True, will allow partial matches. + raise_not_found : bool, optional + If True raise Exception if no match for any of the + query annotations is found. Else log warning. + remote_instance : CatmaidInstance, optional + If not passed directly, will try using global. + + Returns + ------- + dict + ``{'annotation_name': 'annotation_id', ...}`` + + """ + remote_instance = utils._eval_remote_instance(remote_instance) + + logger.debug("Retrieving list of annotations...") + + remote_annotation_list_url = remote_instance._get_annotation_list() + an_list = remote_instance.fetch(remote_annotation_list_url) + + # Turn into pandas array + an_list = pd.DataFrame.from_records(an_list["annotations"]) + + annotations = utils._make_iterable(annotations) + annotation_ids = {} + for an in annotations: + filt = filter_by_query(an_list.name, an, allow_partial) + + # Search for matches + res = an_list[filt].set_index("name").id.to_dict() + if not res: + logger.warning('No annotation found for "{}"'.format(an)) + annotation_ids.update(res) + + if not annotation_ids: + if raise_not_found: + raise Exception("No matching annotation(s) found") + else: + logger.warning("No matching annotation(s) found") + + return annotation_ids + class UnknownEntityTypeError(RuntimeError): _known = {"neuron", "annotation", "volume"} @@ -60,7 +158,7 @@ def entities_to_ann_graph(data: dict, by_name: bool): ndata = { "name": e["name"], - "id": e["id"], + "id": int(e["id"]), "type": etype, } node_id = ndata[id_key] @@ -80,7 +178,7 @@ def entities_to_ann_graph(data: dict, by_name: bool): for ann in e.get("annotations", []): edges.append((ann[id_key], node_id, {"is_meta_annotation": is_meta_ann})) - g.add_edges_from(edges) + g.add_edges_from((e for e in edges if e[0] in g.nodes)) return g @@ -281,10 +379,11 @@ def get_entity_graph( annotated_with : Optional[Iterable[Iterable[Union[int, str]]]], default None If not None, only include entities annotated with these annotations. Can be integer IDs or str names (not IDs as strings!). - The inner sets are combined with OR. + The inner iterables are combined with OR. The outer iterable is combined with AND. e.g. for ``[["a", "b"], ["c"]]``, entities must be annotated with ``"c"``, - and at least one of ``"a"`` or ``"b"``, + and at least one of ``"a"`` or ``"b"``. + Nesting is enforced, i.e. ``"a"`` is not a valid argument; it must be ``[["a"]]``. not_annotated_with: Optional[Iterable[Iterable[Union[int, str]]]], default None If not None, only include entites NOT annotated with these. See ``annotated_with`` for more usage details. diff --git a/pymaid/tests/test_utils.py b/pymaid/tests/test_utils.py index b76bf5a0..6c712a98 100644 --- a/pymaid/tests/test_utils.py +++ b/pymaid/tests/test_utils.py @@ -1,4 +1,4 @@ -from pymaid.fetch import filter_by_query +from pymaid.fetch.annotations import filter_by_query import pandas as pd import pytest From 6dbc2166f07cd8ddc3fd85d54b5bf6f8386698a8 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Mon, 20 Mar 2023 13:43:55 +0000 Subject: [PATCH 07/15] literal type ann for 3.7 --- pymaid/fetch/annotations.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pymaid/fetch/annotations.py b/pymaid/fetch/annotations.py index 5e19ab4a..72a0a37a 100644 --- a/pymaid/fetch/annotations.py +++ b/pymaid/fetch/annotations.py @@ -1,4 +1,5 @@ from collections.abc import Iterable +import sys from typing import ( Optional, Literal, @@ -237,8 +238,12 @@ def neurons_to_skeletons( return g +# todo: update when 3.7 is dropped # todo: replace with strenum -EntityType = Literal["neuron", "annotation", "volume", "skeleton"] +if sys.version_info >= (3, 8): + EntityType = Literal["neuron", "annotation", "volume", "skeleton"] +else: + EntityType = str def join_ids(ids: Iterable[int]) -> str: From 77ed952f6f47bf33b7ab6e06e7d6bd888aabd1c1 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Mon, 20 Mar 2023 15:11:51 +0000 Subject: [PATCH 08/15] minor fixes/ doc improvements --- pymaid/fetch/annotations.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pymaid/fetch/annotations.py b/pymaid/fetch/annotations.py index 72a0a37a..2a58ceaa 100644 --- a/pymaid/fetch/annotations.py +++ b/pymaid/fetch/annotations.py @@ -315,14 +315,16 @@ def _get_annotation_ids( id_mapping = {None: None} names = [] for name_or_id in flattened: + if name_or_id is None: + continue if isinstance(name_or_id, str): names.append(name_or_id) else: id_mapping[name_or_id] = name_or_id - ann_ids = get_annotation_id(names, remote_instance=remote_instance) - - id_mapping.update((n, int(aid)) for n, aid in ann_ids.items()) + if names: + ann_ids = get_annotation_id(names, remote_instance=remote_instance) + id_mapping.update((n, int(aid)) for n, aid in ann_ids.items()) return map_nested(nested, id_mapping) @@ -332,7 +334,7 @@ def get_entity_graph( by_name=False, annotated_with: Optional[Iterable[Iterable[Union[int, str]]]] = None, not_annotated_with: Optional[Iterable[Iterable[Union[int, str]]]] = None, - sub_annotated_with: Optional[Iterable[Union[int, str]]] = None, + expand_subannotations: Optional[Iterable[Union[int, str]]] = None, *, remote_instance=None, ) -> nx.DiGraph: @@ -392,7 +394,7 @@ def get_entity_graph( not_annotated_with: Optional[Iterable[Iterable[Union[int, str]]]], default None If not None, only include entites NOT annotated with these. See ``annotated_with`` for more usage details. - sub_annotated_with: Optional[Iterable[Union[int, str]]], default None + expand_subannotations: Optional[Iterable[Union[int, str]]], default None Which annotations in the ``annotated_with``, ``not_annotated_with`` sets to expand into all their sub-annotations (each as an OR group). remote_instance : optional CatmaidInstance @@ -434,7 +436,7 @@ def get_entity_graph( ) = _get_annotation_ids( annotated_with, not_annotated_with, - sub_annotated_with, + expand_subannotations, remote_instance=remote_instance, ) From e0d2a3aa1cd9f3b9fdb6d3f45b39df39860c0a11 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Mon, 20 Mar 2023 15:12:28 +0000 Subject: [PATCH 09/15] get_entity_graph in docs --- docs/source/api.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/source/api.rst b/docs/source/api.rst index ad6d3454..f524eadd 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -59,6 +59,8 @@ Functions to fetch annotations: pymaid.get_annotations pymaid.get_annotation_details pymaid.get_user_annotations + pymaid.get_annotation_id + pymaid.get_entity_graph Nodes ----- From e5d04f7b261f95506fd9c4d1eb1cc112232920be Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Mon, 20 Mar 2023 15:14:36 +0000 Subject: [PATCH 10/15] fix literal import --- pymaid/fetch/annotations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pymaid/fetch/annotations.py b/pymaid/fetch/annotations.py index 2a58ceaa..6e1db1da 100644 --- a/pymaid/fetch/annotations.py +++ b/pymaid/fetch/annotations.py @@ -2,7 +2,6 @@ import sys from typing import ( Optional, - Literal, Callable, List, DefaultDict, @@ -241,6 +240,7 @@ def neurons_to_skeletons( # todo: update when 3.7 is dropped # todo: replace with strenum if sys.version_info >= (3, 8): + from typing import Literal EntityType = Literal["neuron", "annotation", "volume", "skeleton"] else: EntityType = str From 18cfd7177bb308d1400ef4bd4bf6477f7f1749ca Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Mon, 20 Mar 2023 15:30:48 +0000 Subject: [PATCH 11/15] more typing wrangling --- pymaid/fetch/annotations.py | 41 ++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/pymaid/fetch/annotations.py b/pymaid/fetch/annotations.py index 6e1db1da..e389fd51 100644 --- a/pymaid/fetch/annotations.py +++ b/pymaid/fetch/annotations.py @@ -1,4 +1,3 @@ -from collections.abc import Iterable import sys from typing import ( Optional, @@ -11,6 +10,23 @@ Any, ) from collections import defaultdict + +# From 3.9, typing.Iterable is deprecated +# in favour of collections.abc.Iterable. +# collections.abc.Iterable can be used for isinstance checks, +# but not for type annotation until 3.9. +if sys.version_info < (3, 9): + # use this for type annotation + from typing import Iterable as TIterable + + # use this for isinstance checks + from collections.abc import Iterable +else: + # use this for both + from collections.abc import Iterable + + TIterable = Iterable + from itertools import chain import warnings @@ -241,26 +257,27 @@ def neurons_to_skeletons( # todo: replace with strenum if sys.version_info >= (3, 8): from typing import Literal + EntityType = Literal["neuron", "annotation", "volume", "skeleton"] else: EntityType = str -def join_ids(ids: Iterable[int]) -> str: +def join_ids(ids: TIterable[int]) -> str: return ",".join(str(n) for n in ids) -def join_id_sets(id_sets: Iterable[Iterable[int]]) -> List[str]: +def join_id_sets(id_sets: TIterable[TIterable[int]]) -> List[str]: return [join_ids(ids) for ids in id_sets] @cache.undo_on_error def _get_entities( - types: Optional[Iterable[str]] = None, + types: Optional[TIterable[str]] = None, with_annotations: Optional[bool] = None, - annotated_with: Optional[Iterable[Iterable[int]]] = None, - not_annotated_with: Optional[Iterable[Iterable[int]]] = None, - sub_annotated_with: Optional[Iterable[int]] = None, + annotated_with: Optional[TIterable[TIterable[int]]] = None, + not_annotated_with: Optional[TIterable[TIterable[int]]] = None, + sub_annotated_with: Optional[TIterable[int]] = None, *, remote_instance=None, ): @@ -309,7 +326,7 @@ def map_nested(nested, mapping): def _get_annotation_ids( - *ann_lols: Iterable[Iterable[Union[int, str]]], remote_instance=None + *ann_lols: TIterable[TIterable[Union[int, str]]], remote_instance=None ) -> List[List[List[int]]]: nested, flattened = to_nested_and_flat(ann_lols) id_mapping = {None: None} @@ -330,11 +347,11 @@ def _get_annotation_ids( def get_entity_graph( - types: Optional[Iterable[EntityType]] = None, + types: Optional[TIterable[EntityType]] = None, by_name=False, - annotated_with: Optional[Iterable[Iterable[Union[int, str]]]] = None, - not_annotated_with: Optional[Iterable[Iterable[Union[int, str]]]] = None, - expand_subannotations: Optional[Iterable[Union[int, str]]] = None, + annotated_with: Optional[TIterable[TIterable[Union[int, str]]]] = None, + not_annotated_with: Optional[TIterable[TIterable[Union[int, str]]]] = None, + expand_subannotations: Optional[TIterable[Union[int, str]]] = None, *, remote_instance=None, ) -> nx.DiGraph: From 21cf676680c282b97c934ea4f0c9db5b7960f54f Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Fri, 24 Mar 2023 11:54:47 +0000 Subject: [PATCH 12/15] fail-fast false, concurrency instead of cancel-workflow-action --- .github/workflows/run-tests.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 3307e2bd..7190b0de 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -5,7 +5,11 @@ on: [push, pull_request] jobs: build: runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.python-version }}-${{ matrix.igraph }} + cancel-in-progress: true strategy: + fail-fast: false matrix: python-version: - '3.7' @@ -14,11 +18,11 @@ jobs: - '3.10' igraph: ["igraph", "no-igraph"] steps: - # This cancels any such job that is still runnning - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.6.0 - with: - access_token: ${{ github.token }} + # # This cancels any such job that is still runnning + # - name: Cancel Previous Runs + # uses: styfle/cancel-workflow-action@0.6.0 + # with: + # access_token: ${{ github.token }} - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 From ced59a7f70397fb18048a1c7111e67ea71e1c35e Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Fri, 24 Mar 2023 13:27:19 +0000 Subject: [PATCH 13/15] Remove references to deprecated np.int Deprecated in numpy 1.20 in favour of builtin int. Here replaced by np.integer. --- pymaid/fetch/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pymaid/fetch/__init__.py b/pymaid/fetch/__init__.py index 15f8c074..e5b94e83 100644 --- a/pymaid/fetch/__init__.py +++ b/pymaid/fetch/__init__.py @@ -3749,7 +3749,7 @@ def get_paths(sources, targets, n_hops=2, min_synapses=1, return_graph=False, targets = utils._make_iterable(targets).astype(int) sources = utils._make_iterable(sources).astype(int) - if isinstance(n_hops, (int, np.int)): + if isinstance(n_hops, (int, np.integer)): n_hops = [n_hops] if not utils._is_iterable(n_hops): From 04724a7fa828f2814e8646196e51bf990682f458 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Thu, 27 Apr 2023 16:34:38 +0100 Subject: [PATCH 14/15] Bump version to 2.4 --- docs/source/whats_new.rst | 4 ++-- pymaid/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/source/whats_new.rst b/docs/source/whats_new.rst index ba2e9d36..17463025 100644 --- a/docs/source/whats_new.rst +++ b/docs/source/whats_new.rst @@ -10,8 +10,8 @@ What's new? * - Version - Date - - * - Next - - In progress + * - 2.3.0 + - 27/05/23 - - :func:`pymaid.get_annotation_graph` deprecated in favour of the new :func:`pymaid.get_entity_graph`. * - 2.1.0 diff --git a/pymaid/__init__.py b/pymaid/__init__.py index 2d74330d..6c52cda4 100644 --- a/pymaid/__init__.py +++ b/pymaid/__init__.py @@ -1,5 +1,5 @@ -__version__ = "2.3.0" -__version_vector__ = (2, 3, 0) +__version__ = "2.4.0" +__version_vector__ = (2, 4, 0) from . import config From 4ec48dc00d3e694721b2f80d2503c4b46987e186 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Thu, 27 Apr 2023 16:39:46 +0100 Subject: [PATCH 15/15] fix latest version in what's new docs --- docs/source/whats_new.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/whats_new.rst b/docs/source/whats_new.rst index 17463025..7f7ad76b 100644 --- a/docs/source/whats_new.rst +++ b/docs/source/whats_new.rst @@ -10,7 +10,7 @@ What's new? * - Version - Date - - * - 2.3.0 + * - 2.4.0 - 27/05/23 - - :func:`pymaid.get_annotation_graph` deprecated in favour of the new :func:`pymaid.get_entity_graph`.