labdata 0.1.9__tar.gz → 0.1.11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {labdata-0.1.9 → labdata-0.1.11}/PKG-INFO +1 -1
  2. {labdata-0.1.9 → labdata-0.1.11}/labdata/__init__.py +1 -1
  3. {labdata-0.1.9 → labdata-0.1.11}/labdata/compute/pose.py +4 -1
  4. labdata-0.1.11/labdata/dashboard/docstrings.py +265 -0
  5. {labdata-0.1.9 → labdata-0.1.11}/labdata/dashboard/index.py +32 -12
  6. {labdata-0.1.9 → labdata-0.1.11}/labdata/dashboard/schema_graph.py +49 -17
  7. {labdata-0.1.9 → labdata-0.1.11}/labdata/dashboard/sorting.py +149 -52
  8. {labdata-0.1.9 → labdata-0.1.11}/labdata/schema/ephys.py +24 -0
  9. {labdata-0.1.9 → labdata-0.1.11}/labdata/schema/onephoton.py +1 -1
  10. {labdata-0.1.9 → labdata-0.1.11}/labdata.egg-info/PKG-INFO +1 -1
  11. {labdata-0.1.9 → labdata-0.1.11}/labdata.egg-info/SOURCES.txt +1 -0
  12. {labdata-0.1.9 → labdata-0.1.11}/LICENSE +0 -0
  13. {labdata-0.1.9 → labdata-0.1.11}/README.md +0 -0
  14. {labdata-0.1.9 → labdata-0.1.11}/labdata/cli.py +0 -0
  15. {labdata-0.1.9 → labdata-0.1.11}/labdata/compute/__init__.py +0 -0
  16. {labdata-0.1.9 → labdata-0.1.11}/labdata/compute/caiman.py +0 -0
  17. {labdata-0.1.9 → labdata-0.1.11}/labdata/compute/ec2.py +0 -0
  18. {labdata-0.1.9 → labdata-0.1.11}/labdata/compute/ephys.py +0 -0
  19. {labdata-0.1.9 → labdata-0.1.11}/labdata/compute/schedulers.py +0 -0
  20. {labdata-0.1.9 → labdata-0.1.11}/labdata/compute/singularity.py +0 -0
  21. {labdata-0.1.9 → labdata-0.1.11}/labdata/compute/suite2p.py +0 -0
  22. {labdata-0.1.9 → labdata-0.1.11}/labdata/compute/utils.py +0 -0
  23. {labdata-0.1.9 → labdata-0.1.11}/labdata/copy.py +0 -0
  24. {labdata-0.1.9 → labdata-0.1.11}/labdata/dashboard/__init__.py +0 -0
  25. {labdata-0.1.9 → labdata-0.1.11}/labdata/dashboard/admin.py +0 -0
  26. {labdata-0.1.9 → labdata-0.1.11}/labdata/dashboard/ai_utils.py +0 -0
  27. {labdata-0.1.9 → labdata-0.1.11}/labdata/dashboard/compute.py +0 -0
  28. {labdata-0.1.9 → labdata-0.1.11}/labdata/dashboard/console.py +0 -0
  29. {labdata-0.1.9 → labdata-0.1.11}/labdata/dashboard/decisiontask.py +0 -0
  30. {labdata-0.1.9 → labdata-0.1.11}/labdata/dashboard/explorer/__init__.py +0 -0
  31. {labdata-0.1.9 → labdata-0.1.11}/labdata/dashboard/explorer/cellsegmentation.py +0 -0
  32. {labdata-0.1.9 → labdata-0.1.11}/labdata/dashboard/explorer/spikesorting.py +0 -0
  33. {labdata-0.1.9 → labdata-0.1.11}/labdata/dashboard/notes.py +0 -0
  34. {labdata-0.1.9 → labdata-0.1.11}/labdata/dashboard/procedures.py +0 -0
  35. {labdata-0.1.9 → labdata-0.1.11}/labdata/dashboard/segmentation.py +0 -0
  36. {labdata-0.1.9 → labdata-0.1.11}/labdata/dashboard/video.py +0 -0
  37. {labdata-0.1.9 → labdata-0.1.11}/labdata/export.py +0 -0
  38. {labdata-0.1.9 → labdata-0.1.11}/labdata/rules/__init__.py +0 -0
  39. {labdata-0.1.9 → labdata-0.1.11}/labdata/rules/ephys.py +0 -0
  40. {labdata-0.1.9 → labdata-0.1.11}/labdata/rules/imaging.py +0 -0
  41. {labdata-0.1.9 → labdata-0.1.11}/labdata/rules/utils.py +0 -0
  42. {labdata-0.1.9 → labdata-0.1.11}/labdata/s3.py +0 -0
  43. {labdata-0.1.9 → labdata-0.1.11}/labdata/schema/__init__.py +0 -0
  44. {labdata-0.1.9 → labdata-0.1.11}/labdata/schema/general.py +0 -0
  45. {labdata-0.1.9 → labdata-0.1.11}/labdata/schema/histology.py +0 -0
  46. {labdata-0.1.9 → labdata-0.1.11}/labdata/schema/procedures.py +0 -0
  47. {labdata-0.1.9 → labdata-0.1.11}/labdata/schema/tasks.py +0 -0
  48. {labdata-0.1.9 → labdata-0.1.11}/labdata/schema/twophoton.py +0 -0
  49. {labdata-0.1.9 → labdata-0.1.11}/labdata/schema/utils.py +0 -0
  50. {labdata-0.1.9 → labdata-0.1.11}/labdata/schema/video.py +0 -0
  51. {labdata-0.1.9 → labdata-0.1.11}/labdata/stacks.py +0 -0
  52. {labdata-0.1.9 → labdata-0.1.11}/labdata/utils.py +0 -0
  53. {labdata-0.1.9 → labdata-0.1.11}/labdata/widgets.py +0 -0
  54. {labdata-0.1.9 → labdata-0.1.11}/labdata.egg-info/dependency_links.txt +0 -0
  55. {labdata-0.1.9 → labdata-0.1.11}/labdata.egg-info/entry_points.txt +0 -0
  56. {labdata-0.1.9 → labdata-0.1.11}/labdata.egg-info/requires.txt +0 -0
  57. {labdata-0.1.9 → labdata-0.1.11}/labdata.egg-info/top_level.txt +0 -0
  58. {labdata-0.1.9 → labdata-0.1.11}/pyproject.toml +0 -0
  59. {labdata-0.1.9 → labdata-0.1.11}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labdata
3
- Version: 0.1.9
3
+ Version: 0.1.11
4
4
  Summary: Package to manage data in experimental neuroscience labs
5
5
  Author-email: Joao Couto <jpcouto@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/jcouto/labdata
@@ -1,4 +1,4 @@
1
- VERSION = "0.1.9"
1
+ VERSION = "0.1.11"
2
2
 
3
3
  from .utils import *
4
4
  from .copy import copy_to_upload_server
@@ -63,7 +63,10 @@ class DeeplabcutCompute(BaseCompute):
63
63
  if self.parameters['mode'] == 'train':
64
64
  for i,r in parameters.iterrows():
65
65
  # go through every parameter and label_set
66
- if (self.model_parameters == json.loads(r.parameters_dict) and
66
+ flag = False
67
+ if type(r.parameters_dict) in [str]:
68
+ flag = (self.model_parameters == json.loads(r.parameters_dict))
69
+ if (flag and
67
70
  self.parameters['model_num'] is None and
68
71
  self.parameters['pose_label_set_num'] == r['pose_label_set_num']):
69
72
  model_num = r.model_num
@@ -0,0 +1,265 @@
1
+ """Docstring viewer tab — browse DataJoint table docstrings, definitions and column comments."""
2
+ import re
3
+ import inspect
4
+ import pandas as pd
5
+ import streamlit as st
6
+
7
+ from schema_graph import (
8
+ _enumerate_tables,
9
+ _get_tier,
10
+ _get_schema_type,
11
+ _get_category,
12
+ )
13
+
14
+
15
+ @st.cache_data(ttl=600)
16
+ def _collect_tables(_schema):
17
+ """Walk the schema and collect per-table metadata for browsing.
18
+
19
+ Parameters
20
+ ----------
21
+ _schema : module
22
+ Loaded project schema module (underscore prefix skips Streamlit hashing).
23
+
24
+ Returns
25
+ -------
26
+ pandas.DataFrame
27
+ One row per master and part table with columns ``key``, ``schema``,
28
+ ``tier``, ``category`` and ``full_table_name``.
29
+ """
30
+ masters, parts_map = _enumerate_tables(_schema)
31
+ rows = []
32
+
33
+ def _add(key, cls):
34
+ try:
35
+ full_name = cls.full_table_name
36
+ except Exception:
37
+ return
38
+ sql_name = full_name.split('.')[-1].strip('`')
39
+ rows.append(dict(
40
+ key=key,
41
+ schema=_get_schema_type(full_name, _schema),
42
+ tier=_get_tier(sql_name),
43
+ category=_get_category(key),
44
+ full_table_name=full_name,
45
+ ))
46
+
47
+ for key, cls in masters.items():
48
+ _add(key, cls)
49
+ for part_key, part_cls in parts_map.get(key, {}).items():
50
+ _add(part_key, part_cls)
51
+
52
+ df = pd.DataFrame(rows)
53
+ if len(df):
54
+ df = df.sort_values(['schema', 'category', 'key']).reset_index(drop=True)
55
+ return df
56
+
57
+
58
+ def _resolve_table(schema, key):
59
+ """Resolve a master or ``Master.Part`` key to its table class."""
60
+ if '.' in key:
61
+ master_name, part_name = key.split('.', 1)
62
+ master = getattr(schema, master_name, None)
63
+ return getattr(master, part_name, None) if master is not None else None
64
+ return getattr(schema, key, None)
65
+
66
+
67
+ def _class_docstring(cls):
68
+ """Return the authored class docstring, without datajoint's appended definition.
69
+
70
+ On ``declare`` DataJoint appends ``\\nTable definition:\\n\\n<definition>`` to
71
+ ``__doc__`` (datajoint/schemas.py). We strip that here so it isn't rendered
72
+ twice and so the leftover column-0 line doesn't defeat ``cleandoc``'s dedent
73
+ (which would render the real docstring as a code block).
74
+ """
75
+ raw = (cls.__doc__ or '').split('\nTable definition:\n\n', 1)[0]
76
+ return inspect.cleandoc(raw).strip()
77
+
78
+
79
+ def _rst_to_md(text):
80
+ """Convert the few reStructuredText constructs we see to markdown."""
81
+ return re.sub(r'``([^`]+)``', r'`\1`', text) # ``literal`` -> `literal`
82
+
83
+
84
+ def _render_docstring(doc):
85
+ """Render a numpydoc/RST docstring as markdown for ``st.markdown``.
86
+
87
+ Uses the ``numpydoc`` parser so every section (Parameters, Returns,
88
+ Raises, Notes, See Also, …) renders consistently and prose is never
89
+ swallowed into an indented code block. Falls back to a plain dedent if
90
+ ``numpydoc`` is unavailable or the parse fails.
91
+ """
92
+ try:
93
+ from numpydoc.docscrape import NumpyDocString
94
+ nd = NumpyDocString(doc)
95
+ except Exception:
96
+ return _rst_to_md(inspect.cleandoc(doc))
97
+
98
+ parts = []
99
+ summary = '\n'.join(nd['Summary']).strip()
100
+ if summary:
101
+ parts.append(summary)
102
+ extended = '\n'.join(nd['Extended Summary']).strip()
103
+ if extended:
104
+ parts.append(extended)
105
+
106
+ for section in ('Parameters', 'Returns', 'Yields', 'Raises', 'Attributes'):
107
+ items = nd[section]
108
+ if not items:
109
+ continue
110
+ bullets = []
111
+ for it in items:
112
+ head = f'**{it.name}**' if it.name else ''
113
+ if it.type:
114
+ head += f' — *{it.type}*' if head else f'*{it.type}*'
115
+ desc = ' '.join(l.strip() for l in it.desc).strip()
116
+ bullets.append(f'- {head}' + (f': {desc}' if desc else ''))
117
+ parts.append(f'**{section}**\n\n' + '\n'.join(bullets))
118
+
119
+ see_also = nd['See Also']
120
+ if see_also:
121
+ bullets = []
122
+ for refs, desc in see_also:
123
+ names = ', '.join(f'`{n}`' for n, _ in refs)
124
+ desc = ' '.join(d.strip() for d in desc).strip()
125
+ bullets.append(f'- {names}' + (f': {desc}' if desc else ''))
126
+ parts.append('**See Also**\n\n' + '\n'.join(bullets))
127
+
128
+ for section in ('Notes', 'References', 'Examples'):
129
+ body = '\n'.join(nd[section]).strip()
130
+ if body:
131
+ parts.append(f'**{section}**\n\n{body}')
132
+
133
+ return _rst_to_md('\n\n'.join(parts))
134
+
135
+
136
+ def _methods_table(cls):
137
+ """Return [(name, signature, doc)] for methods defined on the table.
138
+
139
+ Only methods implemented in the project (not inherited from DataJoint base
140
+ classes) are included — e.g. ``File.get``.
141
+ """
142
+ rows = []
143
+ for name in sorted(dir(cls)):
144
+ if name.startswith('_'):
145
+ continue
146
+ try:
147
+ attr = inspect.getattr_static(cls, name)
148
+ except Exception:
149
+ continue
150
+ func = attr.__func__ if isinstance(attr, (classmethod, staticmethod)) else attr
151
+ if not (inspect.isfunction(func) or inspect.ismethod(func)):
152
+ continue
153
+ if (getattr(func, '__module__', '') or '').startswith('datajoint'):
154
+ continue # skip dj-provided methods
155
+ try:
156
+ sig = str(inspect.signature(func))
157
+ except (ValueError, TypeError):
158
+ sig = '(...)'
159
+ rows.append((name, sig, inspect.getdoc(func) or ''))
160
+ return rows
161
+
162
+
163
+ def _attribute_table(cls):
164
+ """Return a DataFrame of (column, key, type, null, default, comment) or None."""
165
+ try:
166
+ attrs = cls.heading.attributes
167
+ except Exception:
168
+ return None
169
+ rows = []
170
+ for name, a in attrs.items():
171
+ rows.append(dict(
172
+ column=name,
173
+ key='🔑' if a.in_key else '',
174
+ type=a.type,
175
+ null='✓' if a.nullable else '',
176
+ default='' if a.default is None else str(a.default),
177
+ comment=a.comment or '',
178
+ ))
179
+ return pd.DataFrame(rows) if rows else None
180
+
181
+
182
+ def docstrings_tab(schema=None):
183
+ """Streamlit tab: browse table docstrings, DataJoint definitions and column comments.
184
+
185
+ Parameters
186
+ ----------
187
+ schema : module or None, optional
188
+ Project schema module. Loaded from session state when ``None``.
189
+ """
190
+ if schema is None:
191
+ schema = st.session_state.get('schema')
192
+ if schema is None:
193
+ st.error('No schema available.')
194
+ return
195
+
196
+ st.write('## Table reference')
197
+
198
+ tables = _collect_tables(schema)
199
+ if not len(tables):
200
+ st.info('No tables found in the loaded schema.')
201
+ return
202
+
203
+ filt_col, cat_col, search_col = st.columns([1, 1, 2])
204
+ with filt_col:
205
+ schemas = ['(all)'] + sorted(tables['schema'].unique())
206
+ sel_schema = st.selectbox('Schema', schemas, key='docstr_schema')
207
+ with cat_col:
208
+ cats = ['(all)'] + sorted(tables['category'].unique())
209
+ sel_cat = st.selectbox('Category', cats, key='docstr_cat')
210
+ with search_col:
211
+ query = st.text_input('Search', key='docstr_search',
212
+ placeholder='Filter table names…').strip().lower()
213
+
214
+ view = tables
215
+ if sel_schema != '(all)':
216
+ view = view[view['schema'] == sel_schema]
217
+ if sel_cat != '(all)':
218
+ view = view[view['category'] == sel_cat]
219
+ if query:
220
+ view = view[view['key'].str.lower().str.contains(query)]
221
+
222
+ if not len(view):
223
+ st.info('No tables match the current filters.')
224
+ return
225
+
226
+ sel_key = st.selectbox(
227
+ f'Table ({len(view)})',
228
+ list(view['key']),
229
+ key='docstr_table',
230
+ )
231
+
232
+ cls = _resolve_table(schema, sel_key)
233
+ if cls is None:
234
+ st.error(f'Could not resolve table `{sel_key}`.')
235
+ return
236
+
237
+ meta = tables[tables['key'] == sel_key].iloc[0]
238
+ st.caption(f'`{meta["full_table_name"]}` — {meta["schema"]} schema · '
239
+ f'{meta["tier"]} · {meta["category"]}')
240
+
241
+ doc = _class_docstring(cls)
242
+ if doc:
243
+ st.markdown(_render_docstring(doc))
244
+ else:
245
+ st.caption('_No class docstring._')
246
+
247
+ definition = getattr(cls, 'definition', None)
248
+ if definition:
249
+ st.write('### Definition')
250
+ st.code(inspect.cleandoc(definition), language='text')
251
+
252
+ attr_df = _attribute_table(cls)
253
+ if attr_df is not None:
254
+ st.write('### Columns')
255
+ st.dataframe(attr_df, hide_index=True, width='stretch')
256
+
257
+ methods = _methods_table(cls)
258
+ if methods:
259
+ st.write('### Methods')
260
+ for name, sig, mdoc in methods:
261
+ st.markdown(f'**`{name}{sig}`**')
262
+ if mdoc:
263
+ st.markdown(_render_docstring(mdoc))
264
+ else:
265
+ st.caption('_No docstring._')
@@ -294,6 +294,7 @@ from segmentation import segmentation_tab
294
294
  from video import video_tab
295
295
  from decisiontask import decisionmaking_tab
296
296
  from compute import compute_tab
297
+ from docstrings import docstrings_tab
297
298
 
298
299
  @st.cache_resource
299
300
  def load_schema(project):
@@ -365,16 +366,17 @@ if show_admin:
365
366
  get_schema_sizes(schema)
366
367
  get_project_users(schema)
367
368
  st.session_state['project'] = selected
368
- st.session_state.pop('sidebar_tab', None)
369
+ for _k in ('active_tab', 'main_nav', 'ref_nav'):
370
+ st.session_state.pop(_k, None)
369
371
  st.rerun()
370
372
 
371
373
  page_names_to_funcs = {}
372
374
 
373
375
  if show_admin:
374
376
  from admin import admin_tab
375
- from schema_graph import schema_graph_tab
376
377
  page_names_to_funcs["**Admin**"] = partial(admin_tab, schema=schema)
377
- page_names_to_funcs["**Tables graph**"] = partial(schema_graph_tab, schema=schema)
378
+
379
+ from schema_graph import schema_graph_tab
378
380
 
379
381
  page_names_to_funcs.update({
380
382
  "**Subjects**": partial(intro_tab, schema = schema),
@@ -395,16 +397,34 @@ for p in plugins.keys():
395
397
  if hasattr(plugins[p],'dashboard_function'):
396
398
  page_names_to_funcs[plugins[p].dashboard_name] = partial(plugins[p].dashboard_function, schema = schema)
397
399
 
400
+ # Reference tabs live at the bottom of the selector, below the docs link.
401
+ page_names_to_funcs["**Table reference**"] = partial(docstrings_tab, schema = schema)
402
+ page_names_to_funcs["**Tables graph**"] = partial(schema_graph_tab, schema = schema)
403
+
404
+ # Split the selector into the main tabs and the reference tabs (bottom group).
405
+ _ref_names = [n for n in ("**Table reference**", "**Tables graph**") if n in page_names_to_funcs]
406
+ _main_names = [n for n in page_names_to_funcs if n not in _ref_names]
407
+
408
+ if st.session_state.get('active_tab') not in page_names_to_funcs:
409
+ st.session_state['active_tab'] = _main_names[0]
410
+ st.session_state['main_nav'] = _main_names[0]
411
+ st.session_state['ref_nav'] = None
412
+
413
+ def _nav_main():
414
+ if st.session_state.get('main_nav') is not None:
415
+ st.session_state['active_tab'] = st.session_state['main_nav']
416
+ st.session_state['ref_nav'] = None
417
+
418
+ def _nav_ref():
419
+ if st.session_state.get('ref_nav') is not None:
420
+ st.session_state['active_tab'] = st.session_state['ref_nav']
421
+ st.session_state['main_nav'] = None
422
+
423
+ st.sidebar.radio("``labdata dashboard``", _main_names, key='main_nav', on_change=_nav_main)
424
+ st.sidebar.radio("Reference · [📖 Documentation](https://jcouto.github.io/labdata-docs/)",
425
+ _ref_names, key='ref_nav', index=None, on_change=_nav_ref)
398
426
 
399
- _tab_options = list(page_names_to_funcs.keys())
400
- _tab_default = st.session_state.get('sidebar_tab', _tab_options[0])
401
- if _tab_default not in _tab_options:
402
- _tab_default = _tab_options[0]
403
- tab_name = st.sidebar.radio(
404
- "``labdata dashboard``",
405
- _tab_options,
406
- index=_tab_options.index(_tab_default),
407
- key='sidebar_tab')
427
+ tab_name = st.session_state['active_tab']
408
428
 
409
429
  try:
410
430
  page_names_to_funcs[tab_name]()
@@ -30,11 +30,11 @@ _CATEGORY_RULES = [
30
30
  'choice', 'running']),
31
31
  ]
32
32
  # Horizontal band center per category (pixels); spacing between bands
33
- _CAT_SPACING = 1200
33
+ _CAT_SPACING = 700
34
34
  _CATEGORY_X = {name: i * _CAT_SPACING for i, (name, _) in enumerate(_CATEGORY_RULES)}
35
35
  _CATEGORY_X['other'] = len(_CATEGORY_RULES) * _CAT_SPACING
36
- _LEVEL_SPACING = 350 # vertical pixels between topological levels
37
- _NODE_JITTER = 280 # horizontal spread when multiple nodes share category+level
36
+ _LEVEL_SPACING = 220 # vertical pixels between topological levels
37
+ _NODE_JITTER = 170 # horizontal spread when multiple nodes share category+level
38
38
 
39
39
 
40
40
  def _get_category(key):
@@ -95,8 +95,24 @@ def _enumerate_tables(schema_module):
95
95
  return masters, parts
96
96
 
97
97
 
98
- # Cached graph data
98
+ @st.cache_data(ttl=600)
99
+ def _approx_row_count(full_table_name):
100
+ """Fast (approximate) row count from information_schema.
101
+
102
+ Gets the estimate from ``TABLE_ROWS`` which is faster than ``SELECT COUNT(*)``.
103
+ Returns the estimate, or ``None`` if it can't be determined.
104
+ """
105
+ try:
106
+ db, tbl = (p.strip('`') for p in full_table_name.split('.'))
107
+ res = dj.conn().query(
108
+ "SELECT table_rows FROM information_schema.tables "
109
+ "WHERE table_schema=%s AND table_name=%s", (db, tbl)).fetchone()
110
+ return int(res[0]) if res and res[0] is not None else None
111
+ except Exception:
112
+ return None
99
113
 
114
+
115
+ # Cached graph data
100
116
  @st.cache_data(ttl=600)
101
117
  def build_graph_data(_schema, included_schemas, included_tiers):
102
118
  """
@@ -195,6 +211,16 @@ def build_graph_data(_schema, included_schemas, included_tiers):
195
211
  x_offset = (i - (n - 1) / 2) * _NODE_JITTER
196
212
  positions[k] = (cx + x_offset, cy)
197
213
 
214
+ # Mean-center coordinates around the origin so the graph renders centered
215
+ # (all-positive x otherwise puts it to the right and vis.js auto-fit slides
216
+ # it to center on every rerun, which reads as a jump on click).
217
+ if positions:
218
+ xs = [p[0] for p in positions.values()]
219
+ ys = [p[1] for p in positions.values()]
220
+ ox = (min(xs) + max(xs)) / 2
221
+ oy = (min(ys) + max(ys)) / 2
222
+ positions = {k: (x - ox, y - oy) for k, (x, y) in positions.items()}
223
+
198
224
  nodes = [
199
225
  {**node_meta[n], 'key': n, 'level': levels.get(n, 0),
200
226
  'x': positions[n][0], 'y': positions[n][1]}
@@ -219,7 +245,7 @@ def build_graph_data(_schema, included_schemas, included_tiers):
219
245
  continue
220
246
  n_parts = len(part_dict)
221
247
  px = master_x + (idx - (n_parts - 1) / 2) * _NODE_JITTER
222
- py = (master_lv + 1) * _LEVEL_SPACING
248
+ py = positions[master_key][1] + _LEVEL_SPACING
223
249
  pn.append(dict(
224
250
  key=part_key,
225
251
  display=part_key.split('.')[-1],
@@ -242,13 +268,7 @@ def build_graph_data(_schema, included_schemas, included_tiers):
242
268
  # Tab entry point
243
269
 
244
270
  def schema_graph_tab(schema, **kwargs):
245
- '''Streamlit tab: interactive DataJoint schema dependency graph.
246
-
247
- Renders a physics-enabled force-directed graph (via ``streamlit-agraph``)
248
- where nodes are tables colour-coded by category (lab, subject, ephys,
249
- imaging, behaviour…) and edges show foreign-key dependencies. Nodes can
250
- be filtered by schema type (Manual, Computed, Imported, Lookup) and by
251
- category.
271
+ '''Streamlit tab: interactive datajoint schema dependency graph.
252
272
 
253
273
  Parameters
254
274
  ----------
@@ -341,7 +361,9 @@ def schema_graph_tab(schema, **kwargs):
341
361
  color='#555555', arrows='to',
342
362
  ))
343
363
 
344
- # Config
364
+ # Config. Seed coordinates are mean-centered (see build_graph_data) so the
365
+ # graph starts centered and vis.js's auto-fit has nothing to slide on a
366
+ # click-triggered rerun (that was the "jump from right to center").
345
367
  config = Config(
346
368
  width='100%',
347
369
  height=500,
@@ -349,6 +371,17 @@ def schema_graph_tab(schema, **kwargs):
349
371
  physics=True,
350
372
  hierarchical=False,
351
373
  )
374
+ # barnesHut force tuning: with physics on, these (not the seed spacing)
375
+ # determine the final layout density. Weaker repulsion + stronger central
376
+ # gravity + shorter springs => a more compact cluster.
377
+ config.physics['barnesHut'] = {
378
+ 'gravitationalConstant': -800,
379
+ 'centralGravity': 0.5,
380
+ 'springLength': 90,
381
+ 'springConstant': 0.05,
382
+ 'damping': 0.3,
383
+ 'avoidOverlap': 0.2,
384
+ }
352
385
 
353
386
  # Render
354
387
  clicked = agraph(nodes=agraph_nodes, edges=agraph_edges, config=config)
@@ -381,10 +414,9 @@ def schema_graph_tab(schema, **kwargs):
381
414
  if cls:
382
415
  break
383
416
  if cls:
384
- try:
385
- st.metric('Rows', len(cls()))
386
- except Exception:
387
- pass
417
+ approx = _approx_row_count(node['full_table_name'])
418
+ if approx is not None:
419
+ st.metric('Rows (approx.)', f'~{approx:,}')
388
420
 
389
421
 
390
422
  st.caption(
@@ -20,9 +20,10 @@ def _fetch_unit_data(_schema, subject_name, session_name, dataset_name,
20
20
  probe_num=probe_num)
21
21
  unit_keys = [dict(unit_id=u) for u in unit_ids_tuple]
22
22
 
23
- # spike times + amplitudes from SpikeSorting.Unit
23
+ # spike times + amplitudes + positions from SpikeSorting.Unit
24
24
  unit_rows = (_schema.SpikeSorting.Unit & key & unit_keys).fetch(
25
- 'unit_id', 'spike_times', 'spike_amplitudes', as_dict=True)
25
+ 'unit_id', 'spike_times', 'spike_amplitudes', 'spike_positions',
26
+ as_dict=True)
26
27
 
27
28
  # sampling-rate / sync conversion (matches get_spike_times logic)
28
29
  try:
@@ -39,6 +40,7 @@ def _fetch_unit_data(_schema, subject_name, session_name, dataset_name,
39
40
 
40
41
  spike_times = {}
41
42
  spike_amplitudes = {}
43
+ spike_positions = {}
42
44
  for r in unit_rows:
43
45
  arr = np.asarray(r['spike_times'], dtype=np.float64)
44
46
  if interp is not None:
@@ -49,6 +51,11 @@ def _fetch_unit_data(_schema, subject_name, session_name, dataset_name,
49
51
  if r['spike_amplitudes'] is not None:
50
52
  spike_amplitudes[int(r['unit_id'])] = np.asarray(
51
53
  r['spike_amplitudes'], dtype=np.float64)
54
+ if r['spike_positions'] is not None:
55
+ pos = np.asarray(r['spike_positions'], dtype=np.float64)
56
+ # column 1 is depth along the probe (µm)
57
+ spike_positions[int(r['unit_id'])] = (
58
+ pos[:, 1] if pos.ndim == 2 and pos.shape[1] > 1 else pos.ravel())
52
59
 
53
60
  # waveforms
54
61
  wf_rows = (_schema.SpikeSorting.Waveforms & key & unit_keys).fetch(
@@ -65,7 +72,7 @@ def _fetch_unit_data(_schema, subject_name, session_name, dataset_name,
65
72
  (_schema.SpikeSorting & key).fetch1('sorting_channel_coords'),
66
73
  dtype=np.float64)
67
74
 
68
- return spike_times, spike_amplitudes, waveforms, chmap
75
+ return spike_times, spike_amplitudes, spike_positions, waveforms, chmap
69
76
 
70
77
 
71
78
  def _xcorr(x, y, maxlag):
@@ -206,8 +213,11 @@ def _unit_panel(schema, plot_df, sel_key, unit_id_col, amp_col,
206
213
  _tdata = _tdata[_tdata['_pass'] == 'pass']
207
214
  _sort_cols = (['shank'] if 'shank' in _tdata.columns else []) + ['_depth']
208
215
  _tdata = (_tdata.sort_values(_sort_cols)
209
- .drop(columns=['_pass', '_depth'])
216
+ .drop(columns=['_depth'])
210
217
  .reset_index(drop=True))
218
+ # positional mask of rows that fail the SUA criteria (greyed out below)
219
+ _fail_mask = (_tdata['_pass'] == 'fail').values
220
+ _tdata = _tdata.drop(columns=['_pass'])
211
221
 
212
222
  # Cursor: index of the currently active unit in _tdata (for n/b navigation)
213
223
  _cur_key = (f'srt_tbl_cursor_{sel_key["subject_name"]}_{sel_key["session_name"]}_'
@@ -260,6 +270,8 @@ def _unit_panel(schema, plot_df, sel_key, unit_id_col, amp_col,
260
270
  if unit_id_col and unit_id_col in row.index:
261
271
  if int(row[unit_id_col]) in _selected_ids:
262
272
  return ['background-color: #f5a623; color: #000000'] * len(row)
273
+ if row.name < len(_fail_mask) and _fail_mask[row.name]:
274
+ return ['background-color: #f0f0f0; color: #999999'] * len(row)
263
275
  return [''] * len(row)
264
276
 
265
277
  _tbl_key = (f'srt_unit_table_{sel_key["subject_name"]}_{sel_key["session_name"]}_'
@@ -428,6 +440,7 @@ def _unit_panel(schema, plot_df, sel_key, unit_id_col, amp_col,
428
440
  # all_spike_times / all_spike_amps keyed by (prb_str, unit_id_int)
429
441
  all_spike_times = {}
430
442
  all_spike_amps = {}
443
+ all_spike_pos = {}
431
444
  all_waveforms = [] # each dict has unit_id + probe keys
432
445
  chmap_by_probe = {}
433
446
 
@@ -437,7 +450,7 @@ def _unit_panel(schema, plot_df, sel_key, unit_id_col, amp_col,
437
450
  except ValueError:
438
451
  probe_num_val = prb
439
452
  try:
440
- sp_map, amp_map, wf_data, chmap = _fetch_unit_data(
453
+ sp_map, amp_map, pos_map, wf_data, chmap = _fetch_unit_data(
441
454
  schema,
442
455
  sel_key['subject_name'], sel_key['session_name'],
443
456
  sel_key['dataset_name'], sel_key['parameter_set_num'],
@@ -449,6 +462,8 @@ def _unit_panel(schema, plot_df, sel_key, unit_id_col, amp_col,
449
462
  all_spike_times[(prb, uid)] = v
450
463
  for uid, v in amp_map.items():
451
464
  all_spike_amps[(prb, uid)] = v
465
+ for uid, v in pos_map.items():
466
+ all_spike_pos[(prb, uid)] = v
452
467
  for w in wf_data:
453
468
  all_waveforms.append(dict(w, probe=prb))
454
469
  chmap_by_probe[prb] = chmap
@@ -481,6 +496,71 @@ def _unit_panel(schema, plot_df, sel_key, unit_id_col, amp_col,
481
496
  value=0.1, step=0.01, format='%.3f',
482
497
  key='srt_wf_scale')
483
498
 
499
+ # toggle + spike-count selector for the amplitude/depth over-time plots
500
+ dc1, dc2 = st.columns([1, 2], vertical_alignment='center')
501
+ show_drift = dc1.toggle(
502
+ 'Spikes over time', key='srt_show_drift',
503
+ help='Plot spike amplitude and depth over time for the selected units.')
504
+ drift_n = dc2.number_input(
505
+ 'Number of spikes to plot', min_value=100, max_value=200000,
506
+ value=5000, step=100, key='srt_drift_nspikes',
507
+ help='A random subset of this many spikes per unit is plotted.')
508
+
509
+ if show_drift and ordered_units:
510
+ _drift_rows = []
511
+ _rng = np.random.default_rng(0)
512
+ for prb, uid in ordered_units:
513
+ t = all_spike_times.get((prb, uid))
514
+ if t is None or not len(t):
515
+ continue
516
+ n = len(t)
517
+ idx = (np.sort(_rng.choice(n, int(drift_n), replace=False))
518
+ if n > drift_n else np.arange(n))
519
+ a = all_spike_amps.get((prb, uid))
520
+ p = all_spike_pos.get((prb, uid))
521
+ _drift_rows.append(pd.DataFrame({
522
+ 'time_s': np.asarray(t)[idx],
523
+ 'unit': _ulabel(prb, uid),
524
+ 'amplitude': (np.asarray(a)[idx]
525
+ if a is not None and len(a) >= n
526
+ else np.full(len(idx), np.nan)),
527
+ 'depth': (np.asarray(p)[idx]
528
+ if p is not None and len(p) >= n
529
+ else np.full(len(idx), np.nan)),
530
+ }))
531
+ if _drift_rows:
532
+ drift_df = pd.concat(_drift_rows, ignore_index=True)
533
+ alt.data_transformers.disable_max_rows()
534
+ _t_dom = [float(drift_df['time_s'].min()), float(drift_df['time_s'].max())]
535
+ _drift_base = alt.Chart(drift_df).mark_circle(size=10, opacity=0.4).encode(
536
+ x=alt.X('time_s:Q', title='Time (s)', axis=_tax,
537
+ scale=alt.Scale(domain=_t_dom)),
538
+ color=alt.Color('unit:N', scale=unit_color_scale, title='Unit'),
539
+ tooltip=['unit:N',
540
+ alt.Tooltip('time_s:Q', format='.1f', title='Time (s)'),
541
+ alt.Tooltip('amplitude:Q', format='.0f', title='Amplitude'),
542
+ alt.Tooltip('depth:Q', format='.0f', title='Depth (µm)')])
543
+ _dcol1, _dcol2 = st.columns(2)
544
+ if drift_df['amplitude'].notna().any():
545
+ _dcol1.altair_chart(
546
+ _drift_base.encode(
547
+ y=alt.Y('amplitude:Q', title='Amplitude', axis=_tax))
548
+ .add_params(alt.selection_interval(bind='scales',
549
+ name='drift_amp_zoom'))
550
+ .properties(height=280, title='Amplitude over time'),
551
+ width='stretch')
552
+ if drift_df['depth'].notna().any():
553
+ _d_dom = [float(drift_df['depth'].min()) - 50,
554
+ float(drift_df['depth'].max()) + 50]
555
+ _dcol2.altair_chart(
556
+ _drift_base.encode(
557
+ y=alt.Y('depth:Q', title='Depth (µm)', axis=_tax,
558
+ scale=alt.Scale(domain=_d_dom)))
559
+ .add_params(alt.selection_interval(bind='scales',
560
+ name='drift_depth_zoom'))
561
+ .properties(height=280, title='Depth over time'),
562
+ width='stretch')
563
+
484
564
  col_left, col_right = st.columns([3, 2])
485
565
 
486
566
  with col_left:
@@ -815,23 +895,39 @@ def sorting_tab(schema=None):
815
895
  default_param = _get_default_param(schema)
816
896
  default_params = [default_param] if default_param and default_param in param_options else []
817
897
 
818
- tab_sessions, tab_explorer, tab_units = st.tabs(['Sessions', 'Unit explorer', 'Unit counts'])
819
-
820
- # Tab 1: Sessions
821
- with tab_sessions:
822
- sel_params_1 = st.multiselect(
898
+ # Global parameter-set and subject selection (apply to every tab). Apply any
899
+ # pending change requested by a chart click before the widgets are instantiated.
900
+ if '_srt_pending_param' in st.session_state:
901
+ st.session_state['srt_param_sel'] = st.session_state.pop('_srt_pending_param')
902
+ if '_srt_pending_subjects' in st.session_state:
903
+ st.session_state['srt_subjects'] = st.session_state.pop('_srt_pending_subjects')
904
+
905
+ _all_sorted = _get_all_sorting_sessions(schema)
906
+ subject_options = (sorted(_all_sorted['subject_name'].unique().tolist())
907
+ if not _all_sorted.empty else [])
908
+
909
+ _gp, _gs = st.columns(2)
910
+ with _gp:
911
+ st.session_state.setdefault('srt_param_sel', default_params)
912
+ sel_params = st.multiselect(
823
913
  'Parameter sets',
824
914
  options=list(param_options.keys()),
825
915
  format_func=lambda k: param_options[k],
826
- default=default_params,
827
- key='srt_param_sel_sessions',
916
+ key='srt_param_sel',
828
917
  )
829
- selected_params_1 = sel_params_1 if sel_params_1 else list(param_options.keys())
918
+ with _gs:
919
+ selected_subjects = st.multiselect(
920
+ 'Subjects', options=subject_options, key='srt_subjects')
921
+ selected_params = sel_params if sel_params else list(param_options.keys())
922
+
923
+ tab_sessions, tab_explorer, tab_units = st.tabs(['Sessions', 'Unit explorer', 'Unit counts'])
830
924
 
925
+ # Tab 1: Sessions
926
+ with tab_sessions:
831
927
  # histogram: session counts per subject
832
928
  counts_df = _get_session_counts(schema)
833
929
  if not counts_df.empty:
834
- counts_filt = counts_df[counts_df['parameter_set_num'].isin(selected_params_1)]
930
+ counts_filt = counts_df[counts_df['parameter_set_num'].isin(selected_params)]
835
931
  if not counts_filt.empty:
836
932
  param_sets_h = sorted(counts_filt['parameter_set_num'].unique().tolist())
837
933
  bar_color_scale = alt.Scale(
@@ -856,17 +952,22 @@ def sorting_tab(schema=None):
856
952
  bar_pts = (bar_event.selection or {}).get('bar_sel', [])
857
953
  if bar_pts and bar_pts[0].get('subject_name'):
858
954
  bar_pt = bar_pts[0]
859
- bar_subject = bar_pt.get('subject_name')
860
955
  bar_param = str(bar_pt.get('parameter_set_num', ''))
861
- st.session_state['srt_subjects'] = [bar_subject]
862
- if bar_param in param_options:
863
- st.session_state['srt_param_sel_units'] = [bar_param]
956
+ # act only once per distinct selection, otherwise the sticky
957
+ # chart selection keeps overriding manual param/subject changes
958
+ _sig = ('bar', bar_pt.get('subject_name'), bar_param)
959
+ if st.session_state.get('_srt_last_click') != _sig:
960
+ st.session_state['_srt_last_click'] = _sig
961
+ st.session_state['_srt_pending_subjects'] = [bar_pt.get('subject_name')]
962
+ if bar_param in param_options:
963
+ st.session_state['_srt_pending_param'] = [bar_param]
964
+ st.rerun()
864
965
 
865
966
  # all-sessions scatter
866
967
  all_sessions_df = _get_all_sorting_sessions(schema)
867
968
  if not all_sessions_df.empty:
868
969
  scatter_df = all_sessions_df[
869
- all_sessions_df['parameter_set_num'].isin(selected_params_1)
970
+ all_sessions_df['parameter_set_num'].isin(selected_params)
870
971
  ].reset_index(drop=True)
871
972
  if scatter_df.empty:
872
973
  scatter_df = all_sessions_df.copy()
@@ -902,16 +1003,23 @@ def sorting_tab(schema=None):
902
1003
  if scat_pts and scat_pts[0].get('subject_name'):
903
1004
  pt = scat_pts[0]
904
1005
  param_str = str(pt.get('parameter_set_num', ''))
905
- st.session_state['srt_selected_key'] = {
906
- 'subject_name': pt.get('subject_name'),
907
- 'session_name': pt.get('session_name'),
908
- 'dataset_name': pt.get('dataset_name'),
909
- 'parameter_set_num': param_str,
910
- }
911
- # sync tab 2: subject + param set
912
- st.session_state['srt_subjects'] = [pt.get('subject_name')]
913
- if param_str in param_options:
914
- st.session_state['srt_param_sel_units'] = [param_str]
1006
+ # act only once per distinct selection, otherwise the sticky
1007
+ # chart selection keeps overriding manual param/subject changes
1008
+ _sig = ('scat', pt.get('subject_name'), pt.get('session_name'),
1009
+ pt.get('dataset_name'), param_str)
1010
+ if st.session_state.get('_srt_last_click') != _sig:
1011
+ st.session_state['_srt_last_click'] = _sig
1012
+ st.session_state['srt_selected_key'] = {
1013
+ 'subject_name': pt.get('subject_name'),
1014
+ 'session_name': pt.get('session_name'),
1015
+ 'dataset_name': pt.get('dataset_name'),
1016
+ 'parameter_set_num': param_str,
1017
+ }
1018
+ # sync global subject + param set
1019
+ st.session_state['_srt_pending_subjects'] = [pt.get('subject_name')]
1020
+ if param_str in param_options:
1021
+ st.session_state['_srt_pending_param'] = [param_str]
1022
+ st.rerun()
915
1023
 
916
1024
  # Tab 2: Unit explorer
917
1025
  with tab_explorer:
@@ -935,12 +1043,19 @@ def sorting_tab(schema=None):
935
1043
 
936
1044
  if _exp_unit_criteria_id is not None:
937
1045
  sua_df = _get_all_sua_counts(schema, _exp_unit_criteria_id)
1046
+ # restrict to the globally selected parameter set(s)
1047
+ sua_df = sua_df[sua_df['parameter_set_num'].isin(selected_params)]
938
1048
  if not sua_df.empty:
939
1049
  sel_key_now = st.session_state.get('srt_selected_key', {})
940
1050
  _sel_subj = sel_key_now.get('subject_name')
941
- if _sel_subj and _sel_subj in sua_df['subject_name'].values:
1051
+ # follow the global Subjects selector: show every selected subject
1052
+ # (each gets its own colour); fall back to the clicked session's
1053
+ # subject when nothing is selected.
1054
+ if selected_subjects:
1055
+ sua_df = sua_df[sua_df['subject_name'].isin(selected_subjects)]
1056
+ elif _sel_subj and _sel_subj in sua_df['subject_name'].values:
942
1057
  sua_df = sua_df[sua_df['subject_name'] == _sel_subj]
943
- # One row per session: sum SUA across all parameter sets and probes.
1058
+ # One row per session: sum SUA across the selected parameter sets and probes.
944
1059
  # Retain the parameter_set_num with the highest SUA for click-to-load.
945
1060
  _best_param = (sua_df.sort_values('sua', ascending=False)
946
1061
  .drop_duplicates(['subject_name', 'session_name', 'dataset_name'])
@@ -1071,24 +1186,6 @@ def sorting_tab(schema=None):
1071
1186
 
1072
1187
  # Tab 3: Unit counts
1073
1188
  with tab_units:
1074
- sessions_all = schema.SpikeSorting().fetch(format='frame').reset_index()
1075
-
1076
- st.session_state.setdefault('srt_param_sel_units', default_params)
1077
- sel_params_2 = st.multiselect(
1078
- 'Parameter sets',
1079
- options=list(param_options.keys()),
1080
- format_func=lambda k: param_options[k],
1081
- key='srt_param_sel_units',
1082
- )
1083
- selected_params_2 = sel_params_2 if sel_params_2 else list(param_options.keys())
1084
-
1085
- sess2 = sessions_all[sessions_all['parameter_set_num'].isin(
1086
- [int(p) if p.isdigit() else p for p in selected_params_2]
1087
- )]
1088
- subjects2 = np.unique(sess2['subject_name'].values) if len(sess2) else []
1089
-
1090
- selected_subjects2 = st.multiselect('Subject:', subjects2, key='srt_subjects')
1091
-
1092
1189
  criteria_ids2 = schema.UnitCountCriteria().fetch(as_dict=True)
1093
1190
  if not criteria_ids2:
1094
1191
  st.write('Add a UnitCountCriteria to the table: example '
@@ -1106,10 +1203,10 @@ def sorting_tab(schema=None):
1106
1203
  unit_criteria2 = (int(selection2.split('->')[0].strip(' []'))
1107
1204
  if '->' in selection2 else None)
1108
1205
 
1109
- if selected_subjects2 and unit_criteria2 is not None:
1206
+ if selected_subjects and unit_criteria2 is not None:
1110
1207
  units2 = []
1111
- for p in selected_params_2:
1112
- for s in selected_subjects2:
1208
+ for p in selected_params:
1209
+ for s in selected_subjects:
1113
1210
  units2.append(_get_unit_counts(schema, s, p, unit_criteria2))
1114
1211
  units2 = [df for df in units2 if not df.empty]
1115
1212
  if units2:
@@ -697,6 +697,30 @@ class UnitCount(dj.Computed):
697
697
  keys = [dict(a,unit_criteria_id = key['unit_criteria_id']) for i,a in allu.iterrows()]
698
698
  self.Unit.insert(keys, ignore_extra_fields = True) # add to the Unit part table
699
699
 
700
+
701
+ def _apply_unit_criteria(unitmetrics,criteria):
702
+ parameters = [p.strip(' ') for p in criteria.split('&')]
703
+ operators = {'<': np.less, '>': np.greater, '<=':np.less_equal, '>=':np.greater_equal, '==': np.equal, '!=':np.not_equal}
704
+ idx = []
705
+ unitmetrics = unitmetrics.replace({None: np.nan})
706
+ for p in parameters:
707
+ for o in operators.keys():
708
+ if o in p:
709
+ n = [t.strip(' ') for t in p.split(o)]
710
+ try:
711
+ n[1] = float(n[1])
712
+ except:
713
+ print(f'Could not convert {n[1]} to float.')
714
+ pass
715
+ if '|' in n[0]: # then take the absolute value
716
+ n[0] = n[0].strip('|')
717
+ idx.append(operators[o](np.abs(unitmetrics[n[0]].values),n[1]))
718
+ else:
719
+ idx.append(operators[o](unitmetrics[n[0]].values,n[1]))
720
+ for i in idx:
721
+ idx[0] = np.logical_and(idx[0],i)
722
+ return idx[0]
723
+
700
724
  from .histology import Atlas
701
725
 
702
726
  @analysisschema
@@ -24,7 +24,7 @@ class Widefield(dj.Imported):
24
24
  width : int # width of each frame
25
25
  height : int # height of each frame
26
26
  frame_rate : double # frame rate
27
- [nullable] -> File # path to the stack
27
+ -> [nullable] File # path to the stack
28
28
  magnification = NULL : double # magnification
29
29
  objective_angle = NULL : double # angle
30
30
  objective = NULL : varchar(32) # objective
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labdata
3
- Version: 0.1.9
3
+ Version: 0.1.11
4
4
  Summary: Package to manage data in experimental neuroscience labs
5
5
  Author-email: Joao Couto <jpcouto@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/jcouto/labdata
@@ -30,6 +30,7 @@ labdata/dashboard/ai_utils.py
30
30
  labdata/dashboard/compute.py
31
31
  labdata/dashboard/console.py
32
32
  labdata/dashboard/decisiontask.py
33
+ labdata/dashboard/docstrings.py
33
34
  labdata/dashboard/index.py
34
35
  labdata/dashboard/notes.py
35
36
  labdata/dashboard/procedures.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes