diff --git a/docs/conf.py b/docs/conf.py index 8c599b47..9042a7e5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -91,9 +91,9 @@ # built documents. # # The short X.Y version. -version = '0.82' +version = '0.84' # The full version, including alpha/beta/rc tags. -release = '0.82' +release = '0.84' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/requirements.txt b/docs/requirements.txt index 0794c512..ab905657 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,4 @@ ###### Requirements without Version Specifiers ###### -numpydoc nbsphinx==0.3.3 +numpydoc==0.8.0 ipykernel \ No newline at end of file diff --git a/pymaid/__init__.py b/pymaid/__init__.py index 1759c100..c42896e4 100644 --- a/pymaid/__init__.py +++ b/pymaid/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.82" +__version__ = "0.84" from pymaid import config diff --git a/pymaid/core.py b/pymaid/core.py index 864e8423..c59b8b23 100644 --- a/pymaid/core.py +++ b/pymaid/core.py @@ -280,6 +280,20 @@ def __init__(self, x, remote_instance=None, meta_data=None): except BaseException: pass + def __dir__(self): + """ Custom __dir__ to add some parameters that we want to make + searchable. + """ + add_attributes = ['n_open_ends', 'n_branch_nodes', 'n_end_nodes', + 'cable_length', 'root', 'neuron_name', + 'nodes', 'annotations', 'partners', 'review_status', + 'connectors', 'presynapses', 'postsynapses', + 'gap_junctions', 'soma', 'root', 'tags', + 'n_presynapses', 'n_postsynapses', 'n_connectors', + 'bbox'] + + return list(set(super().__dir__() + add_attributes)) + def __getattr__(self, key): # This is to catch empty neurons (e.g. after pruning) if 'nodes' in self.__dict__ and \ @@ -335,6 +349,8 @@ def __getattr__(self, key): elif key == 'tags': self.get_skeleton() return self.tags + elif key == 'sampling_resolution': + return self.n_nodes / self.cable_length elif key == 'n_open_ends': if 'nodes' in self.__dict__: closed = self.tags.get('ends', []) \ @@ -344,36 +360,43 @@ def __getattr__(self, key): + self.tags.get('soma', []) return len([n for n in self.nodes[self.nodes.type == 'end'].treenode_id.tolist() if n not in closed]) else: + logger.info('No skeleton data available. Use .get_skeleton() to fetch.') return 'NA' elif key == 'n_branch_nodes': if 'nodes' in self.__dict__: return self.nodes[self.nodes.type == 'branch'].shape[0] else: + logger.info('No skeleton data available. Use .get_skeleton() to fetch.') return 'NA' elif key == 'n_end_nodes': if 'nodes' in self.__dict__: return self.nodes[self.nodes.type == 'end'].shape[0] else: + logger.info('No skeleton data available. Use .get_skeleton() to fetch.') return 'NA' elif key == 'n_nodes': if 'nodes' in self.__dict__: return self.nodes.shape[0] else: + logger.info('No skeleton data available. Use .get_skeleton() to fetch.') return 'NA' elif key == 'n_connectors': if 'connectors' in self.__dict__: return self.connectors.shape[0] else: + logger.info('No skeleton data available. Use .get_skeleton() to fetch.') return 'NA' elif key == 'n_presynapses': if 'connectors' in self.__dict__: return self.connectors[self.connectors.relation == 0].shape[0] else: + logger.info('No skeleton data available. Use .get_skeleton() to fetch.') return 'NA' elif key == 'n_postsynapses': if 'connectors' in self.__dict__: return self.connectors[self.connectors.relation == 1].shape[0] else: + logger.info('No skeleton data available. Use .get_skeleton() to fetch.') return 'NA' elif key == 'cable_length': if 'nodes' in self.__dict__: @@ -384,6 +407,13 @@ def __getattr__(self, key): w = nx.get_edge_attributes(self.graph, 'weight').values() return sum(w) / 1000 else: + logger.info('No skeleton data available. Use .get_skeleton() to fetch.') + return 'NA' + elif key == 'bbox': + if 'nodes' in self.__dict__: + return self.nodes.describe().loc[['min','max'],['x','y','z']].values.T + else: + logger.info('No skeleton data available. Use .get_skeleton() to fetch.') return 'NA' else: raise AttributeError('Attribute "%s" not found' % key) @@ -470,7 +500,9 @@ def _clear_temp_attr(self, exclude=[]): for a in [at for at in temp_att if at not in exclude]: try: delattr(self, a) + logger.debug('Neuron {}: {} cleared'.format(self.skeleton_id, a)) except BaseException: + logger.debug('Neuron {}: Unable to clear temporary attribute "{}"'.format(self.skeleton_id, a)) pass temp_node_cols = ['flow_centrality', 'strahler_index'] @@ -844,10 +876,11 @@ def prune_distal_to(self, node, inplace=True): for n in node: prox = graph_utils.cut_neuron(x, n, ret='proximal') + # Reinitialise with proximal data x.__init__(prox, x._remote_instance, x.meta_data) - - # Clear temporary attributes is done by cut_neuron - # x._clear_temp_attr() + # Remove potential "left over" attributes (happens if we use a copy) + x._clear_temp_attr(exclude=['graph', 'igraph', 'type', + 'classify_nodes']) if not inplace: return x @@ -879,7 +912,11 @@ def prune_proximal_to(self, node, inplace=True): for n in node: dist = graph_utils.cut_neuron(x, n, ret='distal') + # Reinitialise with distal data x.__init__(dist, x._remote_instance, x.meta_data) + # Remove potential "left over" attributes (happens if we use a copy) + x._clear_temp_attr(exclude=['graph', 'igraph', 'type', + 'classify_nodes']) # Clear temporary attributes is done by cut_neuron # x._clear_temp_attr() @@ -1431,13 +1468,28 @@ def __len__(self): """Use skeleton ID here, otherwise this is terribly slow.""" return len(self.skeleton_id) + def __dir__(self): + """ Custom __dir__ to add some parameters that we want to make + searchable. + """ + add_attributes = ['n_open_ends', 'n_branch_nodes', 'n_end_nodes', + 'cable_length', 'root', 'neuron_name', + 'nodes', 'annotations', 'partners', 'review_status', + 'connectors', 'presynapses', 'postsynapses', + 'gap_junctions', 'soma', 'root', 'tags', + 'n_presynapses', 'n_postsynapses', 'n_connectors', + 'skeleton_id', 'empty', 'shape', 'bbox'] + + return list(set(super().__dir__() + add_attributes)) + def __getattr__(self, key): if key == 'shape': return (self.__len__(),) elif key in ['n_nodes', 'n_connectors', 'n_presynapses', 'n_postsynapses', 'n_open_ends', 'n_end_nodes', 'cable_length', 'tags', 'igraph', 'soma', 'root', - 'segments', 'graph', 'n_branch_nodes', 'dps']: + 'segments', 'graph', 'n_branch_nodes', 'dps', + 'sampling_resolution']: self.get_skeletons(skip_existing=True) return np.array([getattr(n, key) for n in self.neurons]) elif key == 'neuron_name': @@ -1455,6 +1507,8 @@ def __getattr__(self, key): this_n['skeleton_id'] = n.skeleton_id data.append(this_n) return pd.concat(data, axis=0, ignore_index=True) + elif key == 'bbox': + return self.nodes.describe().loc[['min','max'],['x','y','z']].values.T elif key == '_remote_instance': all_instances = [ n._remote_instance for n in self.neurons if n._remote_instance != None] diff --git a/pymaid/fetch.py b/pymaid/fetch.py index 4229f37f..5edb3efc 100644 --- a/pymaid/fetch.py +++ b/pymaid/fetch.py @@ -1578,9 +1578,8 @@ def get_connector_links(x, with_tags=False, chunk_size=50, remote_instance=None) Parameters ---------- - x : list of connector IDs | CatmaidNeuron | CatmaidNeuronList - Connector ID(s) to retrieve details for. If - CatmaidNeuron/List, will use their connectors. + x : int | CatmaidNeuron | CatmaidNeuronList + Neurons/Skeleton IDs to retrieve link details for. with_tags : bool, optional If True will also return dictionary of connector tags. chunk_size : int, optional diff --git a/pymaid/graph_utils.py b/pymaid/graph_utils.py index 6e398dab..95b9d33c 100644 --- a/pymaid/graph_utils.py +++ b/pymaid/graph_utils.py @@ -887,7 +887,12 @@ def reroot_neuron(x, new_root, inplace=False): def cut_neuron(x, cut_node, ret='both'): - """ Split neuron at given point. Returns two new neurons. + """ Split neuron at given point and returns two new neurons. + + Note + ---- + Split is performed between cut node and its parent node. However, cut node + will still be present in both resulting neurons. Parameters ---------- @@ -970,23 +975,22 @@ def _cut_igraph(x, cut_node, ret): g = x.igraph.copy() # Get vertex index - cut_node = g.vs.find(node_id=cut_node).index + cut_ix = g.vs.find(node_id=cut_node).index # Get edge to parent - e = g.es.find(_source=cut_node) + e = g.es.find(_source=cut_ix) # Remove edge g.delete_edges(e) # Make graph undirected -> otherwise .decompose() throws an error # This issue is fixed in the up-to-date branch of igraph-python - # (which is not on PYPI...) + # (which is not on PyPI O_o ) g.to_undirected(combine_edges='first') - # Get subgraph + # Get subgraph -> fastest way to get sets of nodes for subsetting a, b = g.decompose(mode='WEAK') - - # Important: a,b are now UNDIRECTED graphs -> we must not keep using them. + # IMPORTANT: a,b are now UNDIRECTED graphs -> we must not keep using them! if x.root[0] in a.vs['node_id']: dist_graph, prox_graph = b, a @@ -1004,7 +1008,7 @@ def _cut_igraph(x, cut_node, ret): dist._clear_temp_attr(exclude=['igraph', 'type', 'classify_nodes']) if ret == 'proximal' or ret == 'both': - prox = subset_neuron(x, prox_graph.vs['node_id'], clear_temp=False) + prox = subset_neuron(x, prox_graph.vs['node_id'] + [cut_node], clear_temp=False) # Change new root for dist prox.nodes.loc[prox.nodes.treenode_id == cut_node, 'type'] = 'end' @@ -1104,6 +1108,8 @@ def subset_neuron(x, subset, clear_temp=True, remove_disconnected=True, Cut neuron at specific point. """ + if isinstance(x, core.CatmaidNeuronList) and len(x) == 1: + x = x[0] if not isinstance(x, core.CatmaidNeuron): raise TypeError('Can only process data of type "CatmaidNeuron", not\ diff --git a/pymaid/plotting.py b/pymaid/plotting.py index f393a2c5..eaffbd57 100644 --- a/pymaid/plotting.py +++ b/pymaid/plotting.py @@ -21,8 +21,10 @@ import matplotlib.pyplot as plt import matplotlib.lines as mlines import matplotlib.patches as mpatches +import mpl_toolkits from mpl_toolkits.mplot3d.art3d import Line3DCollection from mpl_toolkits.mplot3d import proj3d +from matplotlib.collections import LineCollection import matplotlib.colors as mcl import random @@ -217,7 +219,6 @@ def plot2d(x, method='2d', **kwargs): >>> # Move camera closer (will make image bigger) >>> ax.dist = 5 - Returns -------- fig, ax : matplotlib figure and axis object @@ -233,6 +234,15 @@ def plot2d(x, method='2d', **kwargs): ``connectors_only`` (boolean, default = False) Plot only connectors, not the neuron. + ``cn_size`` (int | float, default = 1) + Size of connectors. + + ``linewidth`` (int | float, default = .5) + Width of neurites. + + ``linestyle`` (str, default = '-') + Line style of neurites. + ``scalebar`` (int | float, default=False) Adds scale bar. Provide integer/float to set size of scalebar in um. For methods '3d' and '3d_complex', this will create an axis object. @@ -245,6 +255,13 @@ def plot2d(x, method='2d', **kwargs): colors that will be applied to all neurons. Dicts will be mapped onto neurons by skeleton ID. + ``depth_coloring`` (bool, default = False) + If True, will color encode depth (Z). Overrides ``color``. Does not work + with ``method = '3d_complex'``. + + ``cn_mesh_colors`` (bool, default = False) + If True, will use the neuron's color for its connectors too. + ``group_neurons`` (bool, default = False) If True, neurons will be grouped. Works with SVG export (not PDF). Does NOT work with ``method='3d_complex'``. @@ -267,11 +284,11 @@ def plot2d(x, method='2d', **kwargs): _ACCEPTED_KWARGS = ['remote_instance', 'connectors', 'connectors_only', 'ax', 'color', 'view', 'scalebar', 'cn_mesh_colors', 'linewidth', 'cn_size', 'group_neurons', 'scatter_kws', - 'figsize', 'linestyle', 'alpha'] + 'figsize', 'linestyle', 'alpha', 'depth_coloring'] wrong_kwargs = [a for a in kwargs if a not in _ACCEPTED_KWARGS] if wrong_kwargs: raise KeyError('Unknown kwarg(s): {0}. Currently accepted: {1}'.format( - ','.join(wrong_kwargs), ','.join(_ACCEPTED_KWARGS))) + ','.join(wrong_kwargs), ', '.join(_ACCEPTED_KWARGS))) _METHOD_OPTIONS = ['2d', '3d', '3d_complex'] if method not in _METHOD_OPTIONS: @@ -295,6 +312,7 @@ def plot2d(x, method='2d', **kwargs): scalebar = kwargs.get('scalebar', None) group_neurons = kwargs.get('group_neurons', False) alpha = kwargs.get('alpha', .9) + depth_coloring = kwargs.get('depth_coloring', False) scatter_kws = kwargs.get('scatter_kws', {}) @@ -364,6 +382,13 @@ def plot2d(x, method='2d', **kwargs): elif method == '2d' and ax.name == '3d': raise TypeError('Axis must be 2d.') + # Prepare some stuff for depth coloring + if depth_coloring and method == '3d_complex': + raise logger.warning('Depth coloring unavailable for method "{}"'.format(method)) + elif depth_coloring and method == '2d': + all_co = skdata.nodes[['x', 'y', 'z']] + norm = plt.Normalize(vmin=all_co.z.min(), vmax=all_co.z.max()) + if volumes: for v in volumes: c = getattr(v, 'color', (0.9, 0.9, 0.9)) @@ -396,6 +421,8 @@ def plot2d(x, method='2d', **kwargs): lim.append(verts.min(axis=0)) # Create lines from segments + line3D_collections = [] + surf3D_collections = [] for i, neuron in enumerate(config.tqdm(skdata.itertuples(), desc='Plot neurons', total=skdata.shape[0], leave=False, disable=config.pbar_hide)): @@ -409,44 +436,71 @@ def plot2d(x, method='2d', **kwargs): neuron, neuron.segments, modifier=(1, -1, 1)) if method == '2d': - # We have to add (None,None,None) to the end of each slab to - # make that line discontinuous there - coords = np.vstack( - [np.append(t, [[None] * 3], axis=0) for t in coords]) - - this_line = mlines.Line2D(coords[:, 0], coords[:, 1], - lw=linewidth, ls=linestyle, - alpha=alpha, color=this_color, - label='{} - #{}'.format(neuron.neuron_name, - neuron.skeleton_id)) - - ax.add_line(this_line) + if not depth_coloring: + # We have to add (None, None, None) to the end of each slab to + # make that line discontinuous there + coords = np.vstack( + [np.append(t, [[None] * 3], axis=0) for t in coords]) + + this_line = mlines.Line2D(coords[:, 0], coords[:, 1], + lw=linewidth, ls=linestyle, + alpha=alpha, color=this_color, + label='{} - #{}'.format(neuron.neuron_name, + neuron.skeleton_id)) + ax.add_line(this_line) + else: + coords = _tn_pairs_to_coords(neuron, modifier=(1, -1, 1)) + lc = LineCollection(coords[:, :, [0, 1]], + cmap='jet', + norm=norm) + lc.set_array(neuron.nodes.loc[~neuron.nodes.parent_id.isnull(), + 'z'].values) + lc.set_linewidth(linewidth) + lc.set_alpha(alpha) + lc.set_linestyle(linestyle) + lc.set_label('{} - #{}'.format(neuron.neuron_name, + neuron.skeleton_id)) + line = ax.add_collection(lc) for n in soma.itertuples(): + if depth_coloring: + this_color = mpl.cm.jet(norm(n.z)) + s = mpatches.Circle((int(n.x), int(-n.y)), radius=n.radius, alpha=alpha, fill=True, fc=this_color, zorder=4, edgecolor='none') ax.add_patch(s) elif method in ['3d', '3d_complex']: + cmap = mpl.cm.jet if depth_coloring else None + # For simple scenes, add whole neurons at a time -> will speed up rendering if method == '3d': - lc = Line3DCollection([c[:, [0, 2, 1]] for c in coords], + if depth_coloring: + this_coords = _tn_pairs_to_coords(neuron, modifier=(1, -1, 1))[:, :, [0, 2, 1]] + else: + this_coords = [c[:, [0, 2, 1]] for c in coords] + + lc = Line3DCollection(this_coords, color=this_color, label=neuron.neuron_name, alpha=alpha, + cmap = cmap, lw=linewidth, linestyle=linestyle) if group_neurons: lc.set_gid(neuron.neuron_name) ax.add_collection3d(lc) + line3D_collections.append(lc) - # For complex scenes, add each segment as a single collection -> help preventing Z-order errors + # For complex scenes, add each segment as a single collection + # -> help preventing Z-order errors elif method == '3d_complex': for c in coords: lc = Line3DCollection([c[:, [0, 2, 1]]], color=this_color, - lw=linewidth, alpha=alpha, + lw=linewidth, + alpha=alpha, linestyle=linestyle) if group_neurons: lc.set_gid(neuron.neuron_name) @@ -456,6 +510,7 @@ def plot2d(x, method='2d', **kwargs): lim.append(coords.max(axis=0)) lim.append(coords.min(axis=0)) + surf3D_collections.append([]) for n in soma.itertuples(): resolution = 20 u = np.linspace(0, 2 * np.pi, resolution) @@ -469,6 +524,8 @@ def plot2d(x, method='2d', **kwargs): if group_neurons: surf.set_gid(neuron.neuron_name) + surf3D_collections[-1].append(surf) + if connectors or connectors_only: if not cn_mesh_colors: cn_types = {0: 'red', 1: 'blue', 2: 'green', 3: 'magenta'} @@ -693,6 +750,55 @@ def plot2d(x, method='2d', **kwargs): sbar_z.set_gid('{0}_um'.format(scalebar)) """ + def set_depth(): + """Sets depth information for neurons according to camera position.""" + + # Get camera normal vector + alpha= ax.azim*np.pi/180. + beta= ax.elev*np.pi/180. + n = np.array([np.cos(alpha)*np.sin(beta), + np.sin(alpha)*np.cos(beta), + np.sin(beta)]) + + # Modifier for coordinates + modifier = np.array([1, 1, -1]) + + # Calculate min and max z from this position + ns = -np.dot(n, (skdata.nodes[['x','z','y']].values * modifier).T) + z_min, z_max = min(ns), max(ns) + + # Go over all neurons and update Z information + for neuron, lc, surf in zip(skdata, line3D_collections, surf3D_collections): + # Can't use root node -> this is not part of the line collection + nodes = neuron.nodes[~neuron.nodes.parent_id.isnull()] + + # Get all coordinates and get relative Z position + ns = -np.dot(n, (nodes[['x','z','y']].values * modifier).T) + # Use zalpha to map to + color = [1,0,0,1] + #cs = np.array(mpl_toolkits.mplot3d.art3d.zalpha(color, ns))[:,3] * -1 + 1 + cs = (ns - z_min) / (z_max - z_min) + lc.set_array(cs) + + # Get depth of soma(s) + soma_co = neuron.nodes[neuron.nodes.radius > 1][['x','z','y']].values + soma_ns = -np.dot(n, (soma_co * modifier).T) + soma_cs = (soma_ns - z_min) / (z_max - z_min) + + # Set + for cs, s in zip(soma_cs, surf): + s.set_color(cmap(cs)) + + def Update(event): + set_depth() + + if depth_coloring: + if method == '2d': + fig.colorbar(line, ax=ax, fraction=.075, shrink=.5, label='Depth') + elif method == '3d': + fig.canvas.mpl_connect('draw_event', Update) + set_depth() + plt.axis('off') logger.debug('Done. Use matplotlib.pyplot.show() to show plot.') @@ -716,6 +822,39 @@ def _fix_default_dict(x): return x +def _tn_pairs_to_coords(x, modifier=(1, 1, 1)): + """Returns pairs of treenode -> parent node coordinates. + + Parameters + ---------- + x : {pandas DataFrame, CatmaidNeuron} + Must contain the nodes + modifier : ints, optional + Use to modify/invert x/y/z axes. + + Returns + ------- + coords : np.array + [ [[x1,y1,z1], [x2,y2,z2]], [[x3,y3,y4], [x4,y4,z4]] ] + + """ + + if not isinstance(modifier, np.ndarray): + modifier = np.array(modifier) + + nodes = x.nodes[~x.nodes.parent_id.isnull()] + tn_co = nodes.loc[:, ['x', 'y', 'z']].values + parent_co = x.nodes.set_index('treenode_id').loc[nodes.parent_id.values, + ['x', 'y', 'z']].values + + tn_co *= modifier + parent_co *= modifier + + coords = np.append(tn_co, parent_co, axis=1) + + return coords.reshape((coords.shape[0], 2, 3)) + + def _segments_to_coords(x, segments, modifier=(1, 1, 1)): """Turns lists of treenode_ids into coordinates diff --git a/pymaid/rmaid.py b/pymaid/rmaid.py index ff5c714d..cdb68c62 100644 --- a/pymaid/rmaid.py +++ b/pymaid/rmaid.py @@ -467,7 +467,12 @@ def neuron2r(neuron, convert_to_um=False): else: n_py['connectors'] = robjects.r('NULL') - n_py['tags'] = robjects.ListVector(n.tags) + if n.tags: + # Make sure we have integers (not e.g. np.int64) + tags = {k : [int(t) for t in v] for k, v in n.tags.items()} + n_py['tags'] = robjects.ListVector(tags) + else: + n_py['tags'] = robjects.r('NULL') # R neuron objects contain information about URL and response headers # -> since we don't have that (yet), we will create the entries but @@ -535,24 +540,29 @@ def dotprops2py(dp, subset=None): return core.Dotprops(df) -def nblast_allbyall(x, normalize=True, remote_instance=None, n_cores=os.cpu_count(), UseAlpha=False): +def nblast_allbyall(x, normalize=True, remote_instance=None, + n_cores=os.cpu_count(), UseAlpha=False): """ Wrapper to use R's ``nat:nblast_allbyall`` (https://github.com/jefferislab/nat.nblast/). + Notes + ----- + Neurons are automatically resampled to 1 micron. + Parameters ---------- - x : skeleton IDs | CatmaidNeuronList | RCatmaid neurons - Neurons to blast. + x : skeleton IDs | CatmaidNeuronList | RCatmaid neurons + Neurons to blast. remote_instance : Catmaid Instance, optional Only neccessary if only skeleton IDs are provided - normalize : bool, optional - If true, matrix is normalized using z-score. - n_cores : int, optional - Number of cores to use for nblasting. Default is - ``os.cpu_count()``. - UseAlpha : bool, optional - Emphasises neurons' straight parts (backbone) over parts - that have lots of branches. + normalize : bool, optional + If true, matrix is normalized using z-score. + n_cores : int, optional + Number of cores to use for nblasting. Default is + ``os.cpu_count()``. + UseAlpha : bool, optional + Emphasises neurons' straight parts (backbone) over + parts that have lots of branches. Returns ------- @@ -653,6 +663,10 @@ def nblast(neuron, remote_instance=None, db=None, n_cores=os.cpu_count(), revers recapitulates what elmr's (https://github.com/jefferis/elmr) nblast_fafb does. + Notes + ----- + Neurons are automatically resampled to 1 micron. + Parameters ---------- x diff --git a/pymaid/user_stats.py b/pymaid/user_stats.py index 05bcafd8..4a0da27f 100644 --- a/pymaid/user_stats.py +++ b/pymaid/user_stats.py @@ -68,7 +68,7 @@ def get_team_contributions(teams, neurons=None, remote_instance=None): - """ Get contributions by teams. + """ Get contributions by teams: nodes, reviews, connectors, time invested. Notes ----- @@ -87,21 +87,23 @@ def get_team_contributions(teams, neurons=None, remote_instance=None): 1. Simple user assignments. For example:: - ``{'teamA': ['user1', 'user2'], 'team2': ['user3'], ...]}`` + {'teamA': ['user1', 'user2'], + 'team2': ['user3'], ...]} 2. Users with start and end dates. Start and end date must be either ``datetime.date`` or a single ``pandas.date_range`` object. For example:: - { - 'team1': { - 'user1': (datetime.date(2017, 1, 1), datetime.date(2018, 1, 1)), - 'user2': (datetime.date(2016, 6, 1), datetime.date(2018, 1, 1) + {'team1': { + 'user1': (datetime.date(2017, 1, 1), + datetime.date(2018, 1, 1)), + 'user2': (datetime.date(2016, 6, 1), + datetime.date(2017, 1, 1) } - 'team2': { - 'user3': pandas.date_range('2017-1-1', '2018-1-1'), - } - } + 'team2': { + 'user3': pandas.date_range('2017-1-1', + '2018-1-1'), + }} Mixing both styles is permissible. For second style, use e.g. ``'user1': None`` for no date restrictions @@ -426,7 +428,7 @@ def get_user_contributions(x, teams=None, remote_instance=None): def get_time_invested(x, remote_instance=None, minimum_actions=10, treenodes=True, connectors=True, mode='SUM', - max_inactive_time=3): + max_inactive_time=3, start_date=None, end_date=None): """ Takes a list of neurons and calculates the time individual users have spent working on this set of neurons. @@ -462,6 +464,10 @@ def get_time_invested(x, remote_instance=None, minimum_actions=10, (node/connectors placed/edited) per day. max_inactive_time : int, optional Maximal time inactive in minutes. + start_date : None | tuple | datetime.date, optional + end_date : None | tuple | datetime.date, optional + Restricts time invested to window. Applies to creation + but not edition time! Returns ------- @@ -560,6 +566,12 @@ def _extract_timestamps(ts, desc='Calc'): elif isinstance(x, core.CatmaidNeuronList): skdata = x + if not isinstance(end_date, (datetime.date, type(None))): + end_date = datetime.date(*end_date) + + if not isinstance(start_date, (datetime.date, type(None))): + start_date = datetime.date(*start_date) + # Extract connector and node IDs node_ids = [] connector_ids = [] @@ -576,6 +588,22 @@ def _extract_timestamps(ts, desc='Calc'): # Get details for links link_details = fetch.get_connector_links(skdata) + # link_details contains all links. We have to subset this to existing + # connectors in case the input neurons have been pruned + link_details = link_details[link_details.connector_id.isin(connector_ids)] + + # Remove timestamps outside of date range (if provided) + if start_date: + node_details = node_details[node_details.creation_time >= np.datetime64( + start_date)] + link_details = link_details[link_details.creation_time >= np.datetime64( + start_date)] + if end_date: + node_details = node_details[node_details.creation_time <= np.datetime64( + end_date)] + link_details = link_details[link_details.creation_time <= np.datetime64( + end_date)] + # Dataframe for creation (i.e. the actual generation of the nodes) creation_timestamps = np.append(node_details[['user','creation_time']].values, link_details[['creator_id','creation_time']].values, diff --git a/pymaid/utils.py b/pymaid/utils.py index 19b05869..d76dfd2a 100644 --- a/pymaid/utils.py +++ b/pymaid/utils.py @@ -739,8 +739,8 @@ def to_swc(x, filename=None, export_synapses=False): # Make copy of nodes this_tn = x.nodes.copy() - # Add an index column - this_tn.loc[:, 'index'] = list(range(this_tn.shape[0])) + # Add an index column (must start with "1", not "0") + this_tn.loc[:, 'index'] = list(range(1, this_tn.shape[0] + 1)) # Make a dictionary this_tn = this_tn.set_index('treenode_id') diff --git a/requirements.txt b/requirements.txt index 5bd6a820..4856acad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,21 @@ decorator>=4.1.0 matplotlib==2.2.2 Shapely==1.5.17.post1 +tqdm==4.23.4 tqdm==4.15.0 -numpy==1.14.3 +numpy==1.14.5 six==1.11.0 setuptools==39.0.1 imageio==2.2.0 vispy==0.5.3 -pandas==0.22.0 +pandas==0.23.1 seaborn==0.8.1 requests==2.19.1 requests_futures==0.9.7 networkx==2.1 -scipy==1.0.0 -plotly==2.5.1 -pyqt5 +scipy==1.1.0 +plotly==2.7.0 +pyqt5==5.11.2 pypng==0.0.18 # Below are optional dependencies