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.
- {labdata-0.1.17 → labdata-0.1.18}/PKG-INFO +1 -1
- {labdata-0.1.17 → labdata-0.1.18}/labdata/__init__.py +1 -1
- {labdata-0.1.17 → labdata-0.1.18}/labdata/cli.py +15 -10
- {labdata-0.1.17 → labdata-0.1.18}/labdata/compute/pose.py +13 -1
- {labdata-0.1.17 → labdata-0.1.18}/labdata/compute/utils.py +104 -17
- {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/admin.py +5 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/compute.py +5 -1
- labdata-0.1.18/labdata/dashboard/drift.py +331 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/index.py +7 -1
- {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/sorting.py +258 -140
- {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/video.py +84 -13
- labdata-0.1.18/labdata/dashboard/writes.py +116 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/video.py +119 -49
- {labdata-0.1.17 → labdata-0.1.18}/labdata/utils.py +5 -1
- {labdata-0.1.17 → labdata-0.1.18}/labdata.egg-info/PKG-INFO +1 -1
- {labdata-0.1.17 → labdata-0.1.18}/labdata.egg-info/SOURCES.txt +2 -0
- {labdata-0.1.17 → labdata-0.1.18}/LICENSE +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/README.md +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/compute/__init__.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/compute/caiman.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/compute/ec2.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/compute/ephys.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/compute/schedulers.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/compute/singularity.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/compute/suite2p.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/copy.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/__init__.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/ai_utils.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/console.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/decisiontask.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/docstrings.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/notes.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/procedures.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/schema_graph.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/dashboard/segmentation.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/export.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/rules/__init__.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/rules/ephys.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/rules/imaging.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/rules/utils.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/s3.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/__init__.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/ephys.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/general.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/histology.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/onephoton.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/procedures.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/tasks.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/twophoton.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/schema/utils.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/stacks.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata/widgets.py +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata.egg-info/dependency_links.txt +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata.egg-info/entry_points.txt +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata.egg-info/requires.txt +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/labdata.egg-info/top_level.txt +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/pyproject.toml +0 -0
- {labdata-0.1.17 → labdata-0.1.18}/setup.cfg +0 -0
|
@@ -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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
21
|
-
listing
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|