labdata 0.1.17__tar.gz → 0.1.18__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 (58) hide show
  1. {labdata-0.1.17 → labdata-0.1.18}/PKG-INFO +1 -1
  2. {labdata-0.1.17 → labdata-0.1.18}/labdata/__init__.py +1 -1
  3. {labdata-0.1.17 → labdata-0.1.18}/labdata/cli.py +15 -10
  4. {labdata-0.1.17 → labdata-0.1.18}/labdata/compute/pose.py +13 -1
  5. {labdata-0.1.17 → labdata-0.1.18}/labdata/compute/utils.py +104 -17
  6. {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/admin.py +5 -0
  7. {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/compute.py +5 -1
  8. labdata-0.1.18/labdata/dashboard/drift.py +331 -0
  9. {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/index.py +7 -1
  10. {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/sorting.py +258 -140
  11. {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/video.py +84 -13
  12. labdata-0.1.18/labdata/dashboard/writes.py +116 -0
  13. {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/video.py +119 -49
  14. {labdata-0.1.17 → labdata-0.1.18}/labdata/utils.py +5 -1
  15. {labdata-0.1.17 → labdata-0.1.18}/labdata.egg-info/PKG-INFO +1 -1
  16. {labdata-0.1.17 → labdata-0.1.18}/labdata.egg-info/SOURCES.txt +2 -0
  17. {labdata-0.1.17 → labdata-0.1.18}/LICENSE +0 -0
  18. {labdata-0.1.17 → labdata-0.1.18}/README.md +0 -0
  19. {labdata-0.1.17 → labdata-0.1.18}/labdata/compute/__init__.py +0 -0
  20. {labdata-0.1.17 → labdata-0.1.18}/labdata/compute/caiman.py +0 -0
  21. {labdata-0.1.17 → labdata-0.1.18}/labdata/compute/ec2.py +0 -0
  22. {labdata-0.1.17 → labdata-0.1.18}/labdata/compute/ephys.py +0 -0
  23. {labdata-0.1.17 → labdata-0.1.18}/labdata/compute/schedulers.py +0 -0
  24. {labdata-0.1.17 → labdata-0.1.18}/labdata/compute/singularity.py +0 -0
  25. {labdata-0.1.17 → labdata-0.1.18}/labdata/compute/suite2p.py +0 -0
  26. {labdata-0.1.17 → labdata-0.1.18}/labdata/copy.py +0 -0
  27. {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/__init__.py +0 -0
  28. {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/ai_utils.py +0 -0
  29. {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/console.py +0 -0
  30. {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/decisiontask.py +0 -0
  31. {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/docstrings.py +0 -0
  32. {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/notes.py +0 -0
  33. {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/procedures.py +0 -0
  34. {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/schema_graph.py +0 -0
  35. {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/segmentation.py +0 -0
  36. {labdata-0.1.17 → labdata-0.1.18}/labdata/export.py +0 -0
  37. {labdata-0.1.17 → labdata-0.1.18}/labdata/rules/__init__.py +0 -0
  38. {labdata-0.1.17 → labdata-0.1.18}/labdata/rules/ephys.py +0 -0
  39. {labdata-0.1.17 → labdata-0.1.18}/labdata/rules/imaging.py +0 -0
  40. {labdata-0.1.17 → labdata-0.1.18}/labdata/rules/utils.py +0 -0
  41. {labdata-0.1.17 → labdata-0.1.18}/labdata/s3.py +0 -0
  42. {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/__init__.py +0 -0
  43. {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/ephys.py +0 -0
  44. {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/general.py +0 -0
  45. {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/histology.py +0 -0
  46. {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/onephoton.py +0 -0
  47. {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/procedures.py +0 -0
  48. {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/tasks.py +0 -0
  49. {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/twophoton.py +0 -0
  50. {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/utils.py +0 -0
  51. {labdata-0.1.17 → labdata-0.1.18}/labdata/stacks.py +0 -0
  52. {labdata-0.1.17 → labdata-0.1.18}/labdata/widgets.py +0 -0
  53. {labdata-0.1.17 → labdata-0.1.18}/labdata.egg-info/dependency_links.txt +0 -0
  54. {labdata-0.1.17 → labdata-0.1.18}/labdata.egg-info/entry_points.txt +0 -0
  55. {labdata-0.1.17 → labdata-0.1.18}/labdata.egg-info/requires.txt +0 -0
  56. {labdata-0.1.17 → labdata-0.1.18}/labdata.egg-info/top_level.txt +0 -0
  57. {labdata-0.1.17 → labdata-0.1.18}/pyproject.toml +0 -0
  58. {labdata-0.1.17 → labdata-0.1.18}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labdata
3
- Version: 0.1.17
3
+ Version: 0.1.18
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.17"
1
+ VERSION = "0.1.18"
2
2
 
3
3
  from .utils import *
4
4
  from .copy import copy_to_upload_server
@@ -625,16 +625,21 @@ Available analysis are
625
625
  return
626
626
  from .compute import parse_analysis, run_analysis
627
627
  # parse analysis will check if the analysis is defined
628
- jobids,obj = parse_analysis(analysis = args.analysis,
629
- job_id = args.job,
630
- subject = args.subject,
631
- session = args.session,
632
- datatype = args.datatype,
633
- secondary_args = secondary_args,
634
- multisession = args.multisession,
635
- force_submit = args.force_submit,
636
- full_command = ' '.join(sys.argv[1:]),
637
- project = args.project)
628
+ try:
629
+ jobids,obj = parse_analysis(analysis = args.analysis,
630
+ job_id = args.job,
631
+ subject = args.subject,
632
+ session = args.session,
633
+ datatype = args.datatype,
634
+ secondary_args = secondary_args,
635
+ multisession = args.multisession,
636
+ force_submit = args.force_submit,
637
+ full_command = ' '.join(sys.argv[1:]),
638
+ project = args.project)
639
+ except (ValueError, NotImplementedError) as err:
640
+ # what the request asked for is not there: say what, and stop
641
+ print(f'{tcolor["r"]("Nothing to run:")} {err}')
642
+ sys.exit(1)
638
643
  if not len(jobids):
639
644
  print('Nothing to run.')
640
645
  return
@@ -159,7 +159,19 @@ class DeeplabcutCompute(BaseCompute):
159
159
  raise(NotImplementedError('Specifying no session is not yet implemented'))
160
160
  datasets = []
161
161
  for k in keys:
162
- datasets += (self.schema.DatasetVideo()& k).fetch(as_dict = True)
162
+ videos = (self.schema.DatasetVideo() & k).fetch(as_dict = True)
163
+ if not len(videos):
164
+ raise ValueError(f'Session {k["session_name"]} of {k["subject_name"]} has no video '
165
+ '(nothing in DatasetVideo); videos are registered when the '
166
+ 'session is uploaded.')
167
+ video_name = self.parameters.get('video_name')
168
+ if video_name is not None:
169
+ names = sorted({v['video_name'] for v in videos})
170
+ videos = [v for v in videos if v['video_name'] == video_name]
171
+ if not len(videos):
172
+ raise ValueError(f'Session {k["session_name"]} of {k["subject_name"]} has no video '
173
+ f'named {video_name}; it has {", ".join(names)}.')
174
+ datasets += videos
163
175
  datasets = [dict(subject_name = d['subject_name'],
164
176
  session_name = d['session_name'],
165
177
  dataset_name = d['dataset_name']) for d in datasets]
@@ -1,13 +1,36 @@
1
1
  from ..utils import *
2
2
  import traceback
3
3
 
4
- def load_analysis_object(analysis):
4
+ def available_analyses(project=None):
5
+ '''The compute classes of the preference file that are loaded, by analysis name.
6
+
7
+ An entry whose class is not there, because it comes from a plugin that is
8
+ not loaded, or is not a compute class at all, is left out. With
9
+ ``project`` the project's plugins are loaded first.
10
+ '''
11
+ import labdata
12
+ if project is not None:
13
+ load_project_schema(project)
14
+ analyses = {}
15
+ for name, path in prefs['compute']['analysis'].items():
16
+ try:
17
+ obj = eval(path)
18
+ except (AttributeError, NameError):
19
+ continue
20
+ if isinstance(obj, type) and issubclass(obj, BaseCompute):
21
+ analyses[name] = obj
22
+ return analyses
23
+
24
+
25
+ def load_analysis_object(analysis, project=None):
5
26
  '''Load a compute class by its analysis name from user preferences.
6
27
 
7
28
  Parameters
8
29
  ----------
9
30
  analysis : str
10
31
  Key in ``prefs['compute']['analysis']`` (e.g. ``'spks'``, ``'suite2p'``).
32
+ project : str or None, optional
33
+ Project whose plugins to load before looking for the class.
11
34
 
12
35
  Returns
13
36
  -------
@@ -17,23 +40,22 @@ def load_analysis_object(analysis):
17
40
  Raises
18
41
  ------
19
42
  SystemExit
20
- If the analysis name is not found in preferences, prints a help message
21
- listing available analyses and exits.
43
+ If the analysis is not among the loaded ones, prints a help message
44
+ listing the analyses that are and exits.
22
45
  '''
23
- if not analysis in prefs['compute']['analysis'].keys():
46
+ analyses = available_analyses(project)
47
+ if analysis not in analyses:
48
+ path = prefs['compute']['analysis'].get(analysis)
49
+ where = f' for project {project}' if project else ''
50
+ because = (f' ({path} is not loaded{where}, or is not a compute class)' if path else '')
24
51
  print(f'''
25
- Could not find {tcolor['r'](analysis)} analysis.
26
-
27
- The analysis are {tcolor['g'](','.join(prefs["compute"]["analysis"].keys()))}
28
-
52
+ Could not find {tcolor['r'](analysis)} analysis{because}.
53
+ The analyses are {tcolor['g'](','.join(analyses.keys()))}
29
54
  Add the analysis to the "{tcolor['y']("compute")}" section of the preference file {tcolor['y']("{analysis_name:analysis_object}")}.
30
-
31
55
  ''')
32
- sys.exit()
33
- raise ValueError('Add the analysis to the "compute" section of the preference file {analysis_name:analysis_object}.\n\n')
34
-
35
- import labdata
36
- return eval(prefs['compute']['analysis'][analysis])
56
+ sys.exit(1)
57
+ return analyses[analysis]
58
+
37
59
 
38
60
  def handle_compute(job_id, project=None):
39
61
  '''Instantiate and return the correct compute object for a queued job.
@@ -61,7 +83,7 @@ def handle_compute(job_id, project=None):
61
83
  jobinfo = jobinfo.iloc[0]
62
84
  if jobinfo.task_waiting == 0:
63
85
  print(f'Task {job_id} is running on {jobinfo.task_host}')
64
- obj = load_analysis_object(jobinfo.task_name)(jobinfo.job_id,project)
86
+ obj = load_analysis_object(jobinfo.task_name, project=project)(jobinfo.job_id,project)
65
87
  return obj
66
88
 
67
89
  def parse_analysis(analysis, job_id=None,
@@ -115,7 +137,7 @@ def parse_analysis(analysis, job_id=None,
115
137
  obj : BaseCompute
116
138
  Instantiated compute object.
117
139
  '''
118
- obj = load_analysis_object(analysis)(job_id, project=project)
140
+ obj = load_analysis_object(analysis, project=project)(job_id, project=project)
119
141
  obj.secondary_parse(secondary_args, parameter_number)
120
142
  if obj.job_id is None:
121
143
  # then we have to create jobs and assign
@@ -123,6 +145,9 @@ def parse_analysis(analysis, job_id=None,
123
145
  schema = load_project_schema(project)
124
146
  else:
125
147
  schema = obj.schema
148
+ # a subject or session that is not in the project is the usual reason
149
+ # for finding nothing; say so, with the closest names, before looking
150
+ check_subject_and_session(schema, subject, session, project = project)
126
151
  # first check if there is a task that has already been submitted with the exact same command.
127
152
  # this has a caveat: if the order of the arguments is switched, it wont work..
128
153
  if not full_command is None:
@@ -135,6 +160,12 @@ def parse_analysis(analysis, job_id=None,
135
160
  return [], None
136
161
  if not subject is None or not session is None:
137
162
  datasets = obj.find_datasets(subject_name = subject, session_name = session)
163
+ if datasets is not None and not len(datasets):
164
+ asked = ' '.join(f'{name} {", ".join(_as_names(v))}' for name, v in
165
+ (('subject', subject), ('session', session)) if _as_names(v))
166
+ print(tcolor['r'](f'No dataset for {analysis} matches {asked}.')
167
+ + _datasets_on_record(schema, subject, session))
168
+ return [], obj
138
169
  else:
139
170
  datasets = None
140
171
  job_ids = obj.place_tasks_in_queue(datasets,
@@ -144,6 +175,60 @@ def parse_analysis(analysis, job_id=None,
144
175
  # now we have the job ids, need to figure out how to launch the jobs
145
176
  return job_ids, obj # returns the job ids and the task
146
177
 
178
+ def _as_names(value):
179
+ '''The names in a command line argument: a string, a list of strings, or None.'''
180
+ if value is None:
181
+ return []
182
+ if isinstance(value, str):
183
+ value = [value]
184
+ return [str(v) for v in value if str(v).strip()]
185
+
186
+
187
+ def _datasets_on_record(schema, subject, session):
188
+ '''One line saying what datasets the requested sessions do have, or nothing.'''
189
+ keys = [dict(subject_name = a, session_name = s) for a in _as_names(subject) for s in _as_names(session)]
190
+ if not keys:
191
+ return ''
192
+ rows = (schema.Dataset() & keys).fetch('session_name', 'dataset_name', 'dataset_type', as_dict = True)
193
+ if not rows:
194
+ return ' The session has no dataset on record.'
195
+ have = sorted({f'{r["dataset_name"]} ({r["dataset_type"] or "no type"})' for r in rows})
196
+ return f' On record: {", ".join(have)}.'
197
+
198
+
199
+ def check_subject_and_session(schema, subject=None, session=None, project=None):
200
+ '''Raise a ValueError naming what is not in the project, before a compute looks for datasets.
201
+
202
+ A subject that is not in ``Subject`` is reported with the closest names
203
+ on record; a session that the subject does not have is reported with the
204
+ subject's latest sessions. Nothing happens when neither is given.
205
+ '''
206
+ import difflib
207
+ where = f'project {project}' if project else 'the project'
208
+ subjects = _as_names(subject)
209
+ sessions = _as_names(session)
210
+ if not subjects and not sessions:
211
+ return
212
+ known = list(schema.Subject().fetch('subject_name'))
213
+ for name in subjects:
214
+ if name not in known:
215
+ close = difflib.get_close_matches(name, known, n = 5, cutoff = 0.6)
216
+ hint = f' Close names: {", ".join(close)}.' if close else ''
217
+ raise ValueError(f'Subject {name} is not in {where}.{hint}')
218
+ if sessions and not subjects:
219
+ raise ValueError('A session needs its subject: pass the subject name as well.')
220
+ for name in subjects:
221
+ have = list((schema.Session() & dict(subject_name = name)).fetch(
222
+ 'session_name', order_by = 'session_datetime DESC'))
223
+ for ses in sessions:
224
+ if ses not in have:
225
+ close = difflib.get_close_matches(ses, have, n = 3, cutoff = 0.6)
226
+ hint = (f' Close names: {", ".join(close)}.' if close else
227
+ f' Its latest sessions: {", ".join(have[:5])}.' if have else
228
+ ' It has no sessions.')
229
+ raise ValueError(f'Subject {name} has no session {ses} in {where}.{hint}')
230
+
231
+
147
232
  def check_archived(jobid, check_local=False, schema=None):
148
233
  '''Check whether files assigned to a compute job are in S3 Glacier.
149
234
 
@@ -591,7 +676,9 @@ class BaseCompute():
591
676
  idx += list(filter(lambda x: not x is None,[i if f in s else None for i,s in enumerate(
592
677
  files.file_path.values)]))
593
678
  if len(idx) == 0:
594
- raise ValueError(f'Could not find valid Dataset.DataFiles for {dataset}')
679
+ found = ', '.join(Path(f).name for f in files.file_path.values[:8]) if len(files) else 'no files at all'
680
+ raise ValueError(f'{self.name} needs a file of type {", ".join(self.file_filters)} '
681
+ f'and dataset {dataset} has {found}.')
595
682
  files = files.iloc[idx]
596
683
  if type(dataset) is dict:
597
684
  key = dict(dataset,task_name = self.name)
@@ -3,6 +3,7 @@ from datetime import datetime, timedelta
3
3
 
4
4
  import pandas as pd
5
5
  import streamlit as st
6
+ from writes import log
6
7
  import plotly.express as px
7
8
 
8
9
 
@@ -285,8 +286,10 @@ def _add_user_dialog(project, user):
285
286
  add_user_to_project(project, user)
286
287
  get_project_users.clear()
287
288
  st.session_state['admin_msg'] = ('success', f'Added {user} to {project}.')
289
+ log(f'added user {user} to project {project}')
288
290
  except Exception as e:
289
291
  st.session_state['admin_msg'] = ('error', str(e))
292
+ log(f'could not add user {user} to project {project}: {e}')
290
293
  st.rerun()
291
294
  if col2.button("Cancel", key="add_cancel"):
292
295
  st.rerun()
@@ -304,8 +307,10 @@ def _remove_user_dialog(project, user):
304
307
  remove_user_from_project(project, user)
305
308
  get_project_users.clear()
306
309
  st.session_state['admin_msg'] = ('success', f'Removed {user} from {project}.')
310
+ log(f'removed user {user} from project {project}')
307
311
  except Exception as e:
308
312
  st.session_state['admin_msg'] = ('error', str(e))
313
+ log(f'could not remove user {user} from project {project}: {e}')
309
314
  st.rerun()
310
315
  if col2.button("Cancel", key="remove_cancel"):
311
316
  st.rerun()
@@ -1,7 +1,7 @@
1
1
  import pandas as pd
2
2
  import numpy as np
3
3
  import streamlit as st
4
- from writes import write, show_flash
4
+ from writes import write, show_flash, log
5
5
 
6
6
  STATES = ['COMPLETED', 'WORKING', 'WAITING', 'FAILED', 'CANCELED','ON SERVER']
7
7
  STATE_COLORS = ['#01D186', '#48CAE4', '#F3A21C', '#FF5733', '#F3A21C','#F3A21C']
@@ -268,9 +268,13 @@ def _jobs_panel(table, status_field, target_field, cols, delete_key,
268
268
  st.session_state.pop(f'{delete_key}_resubmit_pending', None)
269
269
  if errors:
270
270
  st.session_state[f'{delete_key}_resubmit_errors'] = errors
271
+ log(f'could not re-submit {len(errors)} of {len(jobs_to_resubmit)} job(s): '
272
+ + '; '.join(str(e) for e in errors))
271
273
  else:
272
274
  st.session_state[f'{delete_key}_msg'] = (
273
275
  f'Re-submitted {len(jobs_to_resubmit)} job(s).')
276
+ log(f're-submitted job(s) {", ".join(str(j) for j in jobs_to_resubmit)}'
277
+ + (f' to {override}' if override else ''))
274
278
  st.rerun()
275
279
  if col_no.button('Cancel', key=f'{delete_key}_resubmit_no'):
276
280
  st.session_state.pop(f'{delete_key}_resubmit_pending', None)
@@ -0,0 +1,331 @@
1
+ '''The drift map and the waveforms over depth of one probe, a view of the Spike Sorting tab.
2
+
3
+ The spikes come straight from ``SpikeSorting.Unit`` and the waveforms from
4
+ ``SpikeSorting.Waveforms``; what was read stays cached, so a session already
5
+ seen comes back at once. The criteria filter keeps the read and the plots
6
+ to the single units, and a click on either plot selects the unit for the
7
+ whole tab.
8
+ '''
9
+ import numpy as np
10
+ import pandas as pd
11
+ import streamlit as st
12
+
13
+ SAMPLE_MAX = 500_000 # spikes kept per probe read
14
+ DRAW_OPTIONS = [20_000, 50_000, 100_000, 200_000, 500_000]
15
+ MAX_CHANNELS = 12 # electrodes drawn per unit waveform
16
+ NEIGHBOURHOOD = 100.0 # µm around the peak electrode, without active electrodes on record
17
+ UNIT_COLOURS = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
18
+ '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
19
+ LABEL_ORDER = [None, 'noise', 'MUA', 'SUA'] # unlabelled, noise, multi unit, single unit
20
+ LABEL_COLOURS = {None: '#c8c8c8', 'noise': '#222222', 'MUA': '#ff7f0e', 'SUA': '#2ca02c'}
21
+
22
+
23
+ def _discrete_scale(colours):
24
+ '''A plotly colorscale that maps the integers 0..n-1 to ``colours``.'''
25
+ n = len(colours)
26
+ scale = []
27
+ for i, c in enumerate(colours):
28
+ scale += [[i / n, c], [(i + 1) / n, c]]
29
+ return scale
30
+
31
+
32
+ @st.cache_data(show_spinner=False)
33
+ def _load_probe(_schema, subject_name, session_name, dataset_name, parameter_set_num, probe_num, unit_ids):
34
+ '''The spikes and waveforms of the units of one probe.
35
+
36
+ ``unit_ids`` restricts the units, or is None for all of them. The spikes
37
+ are shuffled once, with a fixed seed, and at most ``SAMPLE_MAX`` kept, so
38
+ the first n of them are a uniform sample of any size.
39
+
40
+ Returns a dict: ``times`` (seconds), ``depths`` (µm), ``amplitudes`` and
41
+ ``spike_units`` for the sample; ``n_spikes`` in the read; the electrode
42
+ ``coords``; a ``units`` frame (unit_id, depth, shank, n_spikes); and
43
+ ``waveforms``, unit_id -> (electrode indices, median waveform on them).
44
+ '''
45
+ key = dict(subject_name=subject_name, session_name=session_name, dataset_name=dataset_name,
46
+ parameter_set_num=int(parameter_set_num), probe_num=int(probe_num))
47
+ empty = dict(times=np.zeros(0, np.float32), depths=np.zeros(0, np.float32),
48
+ amplitudes=np.zeros(0, np.float32), spike_units=np.zeros(0, np.int32),
49
+ n_spikes=0, coords=np.zeros((0, 2), np.float32),
50
+ units=pd.DataFrame(columns=['unit_id', 'depth', 'shank', 'n_spikes']), waveforms={})
51
+ if unit_ids is not None and not len(unit_ids):
52
+ return empty
53
+ restriction = ({} if unit_ids is None
54
+ else f'unit_id IN ({",".join(str(int(u)) for u in unit_ids)})')
55
+ units = (_schema.SpikeSorting.Unit & key & restriction).get_spike_times(
56
+ extra_keys=['spike_amplitudes', 'spike_positions'], warn=False)
57
+
58
+ times, depths, amplitudes, spike_units, counts = [], [], [], [], {}
59
+ for u in units:
60
+ t = np.asarray(u['spike_times'], dtype=np.float32).ravel()
61
+ n = len(t)
62
+ counts[int(u['unit_id'])] = n
63
+ if not n:
64
+ continue
65
+ pos = None if u['spike_positions'] is None else np.asarray(u['spike_positions'], dtype=np.float32)
66
+ d = (pos[:, 1] if pos is not None and pos.ndim == 2 and pos.shape[1] > 1 and len(pos) == n
67
+ else np.full(n, np.nan, dtype=np.float32))
68
+ a = None if u['spike_amplitudes'] is None else np.asarray(u['spike_amplitudes'], dtype=np.float32).ravel()
69
+ a = a if a is not None and len(a) == n else np.full(n, np.nan, dtype=np.float32)
70
+ times.append(t)
71
+ depths.append(d)
72
+ amplitudes.append(a)
73
+ spike_units.append(np.full(n, int(u['unit_id']), dtype=np.int32))
74
+ n_spikes = int(sum(len(t) for t in times))
75
+ if n_spikes:
76
+ order = np.random.default_rng(0).permutation(n_spikes)[:SAMPLE_MAX]
77
+ times, depths, amplitudes, spike_units = (np.concatenate(x)[order]
78
+ for x in (times, depths, amplitudes, spike_units))
79
+ else:
80
+ times, depths, amplitudes, spike_units = (empty[k] for k in ('times', 'depths', 'amplitudes', 'spike_units'))
81
+
82
+ coords = np.asarray((_schema.SpikeSorting & key).fetch1('sorting_channel_coords'), dtype=np.float32)
83
+ metrics = {int(m['unit_id']): m for m in (_schema.UnitMetrics & key & restriction).fetch(
84
+ 'unit_id', 'depth', 'shank', 'active_electrodes', as_dict=True)}
85
+ waveforms = {}
86
+ peaks = {}
87
+ for w in (_schema.SpikeSorting.Waveforms & key & restriction).fetch('unit_id', 'waveform_median', as_dict=True):
88
+ if w['waveform_median'] is None:
89
+ continue
90
+ wf = np.asarray(w['waveform_median'], dtype=np.float32)
91
+ if wf.ndim != 2 or not wf.size:
92
+ continue
93
+ uid = int(w['unit_id'])
94
+ n_channels = min(wf.shape[1], len(coords))
95
+ peak = int(np.argmax(np.abs(wf[:, :n_channels]).max(axis=0)))
96
+ peaks[uid] = peak
97
+ channels = np.zeros(0, dtype=int)
98
+ active = metrics.get(uid, {}).get('active_electrodes')
99
+ if active is not None:
100
+ channels = np.unique(np.asarray(active).ravel().astype(int))
101
+ channels = channels[(channels >= 0) & (channels < n_channels)]
102
+ if not len(channels):
103
+ dist = np.linalg.norm(coords[:n_channels] - coords[peak], axis=1)
104
+ channels = np.where(dist <= NEIGHBOURHOOD)[0]
105
+ dist = np.linalg.norm(coords[channels] - coords[peak], axis=1)
106
+ channels = channels[np.argsort(dist)][:MAX_CHANNELS]
107
+ waveforms[uid] = (channels, wf[:, channels])
108
+
109
+ rows = []
110
+ for uid, n in counts.items():
111
+ m = metrics.get(uid, {})
112
+ depth = m.get('depth')
113
+ if depth is None:
114
+ own = depths[spike_units == uid]
115
+ depth = (float(np.nanmedian(own)) if len(own) and np.isfinite(own).any()
116
+ else float(coords[peaks[uid], 1]) if uid in peaks else np.nan)
117
+ shank = m.get('shank')
118
+ rows.append(dict(unit_id=uid, depth=float(depth), shank=-1 if shank is None else int(shank), n_spikes=n))
119
+ units_df = (pd.DataFrame(rows).sort_values(['depth', 'unit_id']).reset_index(drop=True)
120
+ if rows else empty['units'])
121
+ return dict(times=times, depths=depths, amplitudes=amplitudes, spike_units=spike_units,
122
+ n_spikes=n_spikes, coords=coords, units=units_df, waveforms=waveforms)
123
+
124
+
125
+ def _only_units(data, unit_ids):
126
+ '''The part of a probe read that belongs to ``unit_ids``.'''
127
+ keep = np.isin(data['spike_units'], list(unit_ids))
128
+ return dict(data,
129
+ times=data['times'][keep], depths=data['depths'][keep],
130
+ amplitudes=data['amplitudes'][keep], spike_units=data['spike_units'][keep],
131
+ units=data['units'][data['units']['unit_id'].isin(unit_ids)].reset_index(drop=True),
132
+ waveforms={u: w for u, w in data['waveforms'].items() if u in unit_ids})
133
+
134
+
135
+ def _drift_figure(data, n_draw, colour_by, label_of, highlight, colours, revision):
136
+ '''The drift map: spike depth over time, coloured by amplitude, unit or label.'''
137
+ import plotly.graph_objects as go
138
+ n = min(int(n_draw), len(data['times']))
139
+ t, d, a, u = (data[k][:n] for k in ('times', 'depths', 'amplitudes', 'spike_units'))
140
+ hover = ('unit %{customdata[0]}<br>%{x:.2f} s · %{y:.0f} µm<br>'
141
+ 'amplitude %{customdata[1]:.0f}<extra></extra>')
142
+ if colour_by == 'unit':
143
+ rank = {uid: i for i, uid in enumerate(data['units']['unit_id'])}
144
+ colour = np.array([rank.get(int(x), 0) % len(UNIT_COLOURS) for x in u], dtype=np.int16)
145
+ marker = dict(color=colour, colorscale=_discrete_scale(UNIT_COLOURS), cmin=0, cmax=len(UNIT_COLOURS))
146
+ elif colour_by == 'label':
147
+ code = {uid: LABEL_ORDER.index(label if label in LABEL_ORDER else None)
148
+ for uid, label in label_of.items()}
149
+ colour = np.array([code.get(int(x), 0) for x in u], dtype=np.int16)
150
+ marker = dict(color=colour, colorscale=_discrete_scale([LABEL_COLOURS[k] for k in LABEL_ORDER]),
151
+ cmin=0, cmax=len(LABEL_ORDER))
152
+ else:
153
+ finite = a[np.isfinite(a)]
154
+ top = float(np.percentile(finite, 95)) if len(finite) else 1.0
155
+ marker = dict(color=a, colorscale='Spectral', reversescale=True, cmin=0, cmax=top)
156
+ fig = go.Figure(go.Scattergl(
157
+ x=t, y=d, mode='markers', name='spikes',
158
+ marker=dict(size=3, opacity=0.35, showscale=False, **marker),
159
+ customdata=np.column_stack([u, a]).tolist(), hovertemplate=hover))
160
+ for uid in highlight:
161
+ sel = u == uid
162
+ fig.add_trace(go.Scattergl(
163
+ x=t[sel], y=d[sel], mode='markers', name=f'unit {uid}',
164
+ marker=dict(size=5, opacity=0.9, color=colours.get(uid, '#d62728')),
165
+ customdata=np.column_stack([u[sel], a[sel]]).tolist(), hovertemplate=hover))
166
+ fig.update_layout(height=430, margin=dict(l=60, r=10, t=10, b=40), template='simple_white',
167
+ xaxis_title='Time (s)', yaxis_title='Depth (µm)', uirevision=revision,
168
+ showlegend=bool(highlight), legend=dict(orientation='h', y=1.02, x=0))
169
+ return fig
170
+
171
+
172
+ def _waveform_figure(data, highlight, colours, scale, spacing, revision):
173
+ '''The waveforms over depth: every unit's median waveform at its electrodes, in depth order.'''
174
+ import plotly.graph_objects as go
175
+ units, coords, waveforms = data['units'], data['coords'], data['waveforms']
176
+ xs, ys = [], []
177
+ lit = {uid: ([], []) for uid in highlight}
178
+ marks = []
179
+ for j, row in enumerate(units.itertuples()):
180
+ uid = int(row.unit_id)
181
+ if uid not in waveforms:
182
+ continue
183
+ channels, wf = waveforms[uid]
184
+ n_samples = wf.shape[0]
185
+ tt = np.linspace(-1, 1, n_samples, dtype=np.float32) * 15
186
+ cx, cy = coords[channels, 0], coords[channels, 1]
187
+ x0 = j * spacing
188
+ centre = float(cx.mean())
189
+ target = lit[uid] if uid in lit else (xs, ys)
190
+ for c in range(len(channels)):
191
+ target[0].append(x0 + tt + 1.2 * (cx[c] - centre))
192
+ target[0].append([np.nan])
193
+ target[1].append(cy[c] + wf[:, c] * scale)
194
+ target[1].append([np.nan])
195
+ marks.append((x0, float(row.depth), uid, int(row.shank), int(row.n_spikes)))
196
+ fig = go.Figure()
197
+ if xs:
198
+ fig.add_trace(go.Scattergl(x=np.concatenate(xs), y=np.concatenate(ys), mode='lines',
199
+ line=dict(color='black', width=1), hoverinfo='skip', showlegend=False))
200
+ for uid in highlight:
201
+ lx, ly = lit.get(uid, ([], []))
202
+ if lx:
203
+ fig.add_trace(go.Scattergl(x=np.concatenate(lx), y=np.concatenate(ly), mode='lines',
204
+ line=dict(color=colours.get(uid, '#d62728'), width=2),
205
+ hoverinfo='skip', name=f'unit {uid}'))
206
+ if marks:
207
+ m = pd.DataFrame(marks, columns=['x', 'depth', 'unit_id', 'shank', 'n_spikes'])
208
+ fig.add_trace(go.Scattergl(
209
+ x=m['x'], y=m['depth'], mode='markers+text', text=m['unit_id'].astype(str),
210
+ textposition='top center', textfont=dict(color='darkorange', size=10),
211
+ marker=dict(size=14, opacity=0.35, color=m['shank'], colorscale='Viridis', showscale=False),
212
+ customdata=m[['unit_id', 'shank', 'n_spikes', 'depth']].values.tolist(),
213
+ hovertemplate=('unit %{customdata[0]}<br>shank %{customdata[1]}<br>'
214
+ '%{customdata[2]} spikes · depth %{customdata[3]:.0f} µm<extra></extra>'),
215
+ showlegend=False, name='units'))
216
+ fig.update_layout(height=720, margin=dict(l=60, r=10, t=10, b=10), template='simple_white',
217
+ xaxis=dict(visible=False), yaxis_title='Depth (µm)', uirevision=revision,
218
+ showlegend=bool(highlight), legend=dict(orientation='h', y=1.02, x=0),
219
+ dragmode='pan')
220
+ return fig
221
+
222
+
223
+ def _fresh_units(event, memory_key):
224
+ '''The units under the points of a plotly selection that are new since the last run.
225
+
226
+ The selection of a chart stays as it is across reruns, so the points seen
227
+ are remembered under ``memory_key`` and only the ones added since count.
228
+ '''
229
+ points = (getattr(event, 'selection', None) or {}).get('points', []) or []
230
+ signature = frozenset((p.get('curve_number'), p.get('point_index')) for p in points)
231
+ seen = st.session_state.get(memory_key, frozenset())
232
+ st.session_state[memory_key] = signature
233
+ units = []
234
+ for p in points:
235
+ if (p.get('curve_number'), p.get('point_index')) in seen:
236
+ continue
237
+ uid = _unit_of(p)
238
+ if uid is not None and uid not in units:
239
+ units.append(uid)
240
+ return units
241
+
242
+
243
+ def _unit_of(point):
244
+ '''The unit id a plotly point carries first in its customdata, or None.
245
+
246
+ The browser hands the row back as a list, or as a dict with string keys
247
+ when plotly sent it as a typed array, or as a bare value.
248
+ '''
249
+ custom = point.get('customdata')
250
+ if isinstance(custom, dict):
251
+ custom = custom.get('0', custom.get(0))
252
+ elif isinstance(custom, (list, tuple)):
253
+ custom = custom[0] if len(custom) else None
254
+ try:
255
+ return int(custom)
256
+ except (TypeError, ValueError):
257
+ return None
258
+
259
+
260
+ def drift_section(schema, sel_key, probe, label_of, sua_units, selected, colours, on_pick):
261
+ '''Draw the drift map and the waveforms over depth of one probe.
262
+
263
+ ``sel_key`` names the session and parameter set, ``probe`` the probe;
264
+ ``label_of`` maps unit ids to their label under the current criteria
265
+ ('SUA', 'MUA', 'noise' or None) and ``sua_units`` are the ids that pass
266
+ it as single units. ``selected`` are the selected unit ids on the probe,
267
+ drawn in ``colours`` (unit id -> colour) on both plots, and
268
+ ``on_pick(unit_ids)`` is called with the units clicked on either plot.
269
+ '''
270
+ c_sua, c_draw, c_colour = st.columns([1.3, 1.8, 1.3], vertical_alignment='bottom')
271
+ sua_only = c_sua.toggle('Single units only', value=True, key='srt_drift_sua_only',
272
+ help='Read and draw only the units that pass the criteria as single units.')
273
+ n_draw = c_draw.select_slider('Spikes drawn', DRAW_OPTIONS, value=100_000, key='srt_drift_n_draw',
274
+ format_func=lambda n: f'{n // 1000}k')
275
+ colour_by = c_colour.selectbox('Colour by', ['amplitude', 'unit', 'label'], key='srt_drift_colour')
276
+ unit_filter = tuple(sorted(int(u) for u in sua_units)) if sua_only else None
277
+ if sua_only and not unit_filter:
278
+ st.caption('No single units on this probe under the criteria; turn the filter off to read every unit.')
279
+ return
280
+ with st.spinner(f'Reading the spikes of probe {probe}…'):
281
+ data = _load_probe(schema, sel_key['subject_name'], sel_key['session_name'], sel_key['dataset_name'],
282
+ int(sel_key['parameter_set_num']), int(probe), unit_filter)
283
+ shanks = sorted(int(x) for x in data['units']['shank'].unique())
284
+ chosen = shanks
285
+ if len(shanks) > 1:
286
+ # the shanks are filtered after the read, so switching them costs nothing.
287
+ # a choice of every shank stays "every shank" when the set changes with the
288
+ # probe or the filter; a narrower choice keeps what still applies
289
+ stored = st.session_state.get('srt_drift_shanks')
290
+ against = st.session_state.get('_srt_drift_shanks_all')
291
+ if stored is None or (against != shanks and sorted(stored) == against):
292
+ st.session_state['srt_drift_shanks'] = shanks
293
+ else:
294
+ valid = [x for x in stored if x in shanks]
295
+ st.session_state['srt_drift_shanks'] = valid or shanks
296
+ st.session_state['_srt_drift_shanks_all'] = shanks
297
+ chosen = st.segmented_control('Shanks', shanks, selection_mode='multi', key='srt_drift_shanks',
298
+ format_func=lambda x: f'shank {x}' if x >= 0 else 'no shank')
299
+ chosen = sorted(chosen) if chosen else shanks
300
+ if chosen != shanks:
301
+ data = _only_units(data, set(data['units'].loc[data['units']['shank'].isin(chosen), 'unit_id']))
302
+ n_units = len(data['units'])
303
+ st.caption(f'{n_units} units · {len(data["times"]):,} spikes'
304
+ + (f' of {data["n_spikes"]:,}' if len(data['times']) != data['n_spikes'] else '')
305
+ + f', {min(n_draw, len(data["times"])):,} drawn'
306
+ + (' · single units only' if unit_filter is not None else '')
307
+ + (f' · shank {", ".join(str(x) for x in chosen)}' if chosen != shanks else ''))
308
+ if not n_units:
309
+ return
310
+ if not np.isfinite(data['depths']).any():
311
+ st.warning('The spikes of this sorting have no positions, so the drift map is empty.')
312
+ unit_ids = {int(u) for u in data['units']['unit_id']}
313
+ highlight = [int(u) for u in selected if int(u) in unit_ids]
314
+ revision = (f'{sel_key["subject_name"]}/{sel_key["session_name"]}/{sel_key["dataset_name"]}/'
315
+ f'{sel_key["parameter_set_num"]}/{probe}/{"sua" if unit_filter is not None else "all"}/'
316
+ + '-'.join(str(x) for x in chosen))
317
+
318
+ event = st.plotly_chart(_drift_figure(data, n_draw, colour_by, label_of, highlight, colours, revision),
319
+ key=f'srt_drift_map_{revision}', on_select='rerun',
320
+ selection_mode=('points', 'box', 'lasso'), width='stretch')
321
+ picked = _fresh_units(event, f'_srt_drift_map_seen_{revision}')
322
+ c_scale, c_spacing = st.columns(2)
323
+ scale = c_scale.slider('Waveform scale (µm per µV)', 0.02, 0.5, 0.1, 0.01, key='srt_drift_wf_scale')
324
+ spacing = c_spacing.slider('Unit spacing (µm)', 40, 300, 120, 10, key='srt_drift_wf_spacing')
325
+ event_wf = st.plotly_chart(_waveform_figure(data, highlight, colours, scale, spacing, revision),
326
+ key=f'srt_drift_wf_{revision}', on_select='rerun',
327
+ selection_mode=('points',), width='stretch')
328
+ picked += [u for u in _fresh_units(event_wf, f'_srt_drift_wf_seen_{revision}') if u not in picked]
329
+ fresh = [u for u in picked if u in unit_ids and u not in highlight]
330
+ if fresh:
331
+ on_pick(fresh)