labdata 0.0.24__tar.gz → 0.0.25__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 (56) hide show
  1. {labdata-0.0.24 → labdata-0.0.25}/PKG-INFO +2 -2
  2. {labdata-0.0.24 → labdata-0.0.25}/labdata/__init__.py +1 -1
  3. {labdata-0.0.24 → labdata-0.0.25}/labdata/dashboard/compute.py +2 -2
  4. {labdata-0.0.24 → labdata-0.0.25}/labdata/dashboard/decisiontask.py +15 -7
  5. labdata-0.0.25/labdata/dashboard/index.py +225 -0
  6. labdata-0.0.25/labdata/dashboard/notes.py +184 -0
  7. labdata-0.0.25/labdata/dashboard/procedures.py +284 -0
  8. labdata-0.0.25/labdata/dashboard/segmentation.py +414 -0
  9. {labdata-0.0.24 → labdata-0.0.25}/labdata/dashboard/sorting.py +1 -1
  10. {labdata-0.0.24 → labdata-0.0.25}/labdata/dashboard/video.py +1 -1
  11. {labdata-0.0.24 → labdata-0.0.25}/labdata/stacks.py +18 -0
  12. {labdata-0.0.24 → labdata-0.0.25}/labdata.egg-info/PKG-INFO +2 -2
  13. {labdata-0.0.24 → labdata-0.0.25}/labdata.egg-info/SOURCES.txt +2 -0
  14. {labdata-0.0.24 → labdata-0.0.25}/labdata.egg-info/requires.txt +1 -1
  15. {labdata-0.0.24 → labdata-0.0.25}/pyproject.toml +1 -1
  16. labdata-0.0.24/labdata/dashboard/index.py +0 -408
  17. labdata-0.0.24/labdata/dashboard/segmentation.py +0 -49
  18. {labdata-0.0.24 → labdata-0.0.25}/LICENSE +0 -0
  19. {labdata-0.0.24 → labdata-0.0.25}/README.md +0 -0
  20. {labdata-0.0.24 → labdata-0.0.25}/labdata/cli.py +0 -0
  21. {labdata-0.0.24 → labdata-0.0.25}/labdata/compute/__init__.py +0 -0
  22. {labdata-0.0.24 → labdata-0.0.25}/labdata/compute/caiman.py +0 -0
  23. {labdata-0.0.24 → labdata-0.0.25}/labdata/compute/ec2.py +0 -0
  24. {labdata-0.0.24 → labdata-0.0.25}/labdata/compute/ephys.py +0 -0
  25. {labdata-0.0.24 → labdata-0.0.25}/labdata/compute/pose.py +0 -0
  26. {labdata-0.0.24 → labdata-0.0.25}/labdata/compute/schedulers.py +0 -0
  27. {labdata-0.0.24 → labdata-0.0.25}/labdata/compute/singularity.py +0 -0
  28. {labdata-0.0.24 → labdata-0.0.25}/labdata/compute/suite2p.py +0 -0
  29. {labdata-0.0.24 → labdata-0.0.25}/labdata/compute/utils.py +0 -0
  30. {labdata-0.0.24 → labdata-0.0.25}/labdata/copy.py +0 -0
  31. {labdata-0.0.24 → labdata-0.0.25}/labdata/dashboard/__init__.py +0 -0
  32. {labdata-0.0.24 → labdata-0.0.25}/labdata/dashboard/explorer/__init__.py +0 -0
  33. {labdata-0.0.24 → labdata-0.0.25}/labdata/dashboard/explorer/cellsegmentation.py +0 -0
  34. {labdata-0.0.24 → labdata-0.0.25}/labdata/dashboard/explorer/spikesorting.py +0 -0
  35. {labdata-0.0.24 → labdata-0.0.25}/labdata/export.py +0 -0
  36. {labdata-0.0.24 → labdata-0.0.25}/labdata/rules/__init__.py +0 -0
  37. {labdata-0.0.24 → labdata-0.0.25}/labdata/rules/ephys.py +0 -0
  38. {labdata-0.0.24 → labdata-0.0.25}/labdata/rules/imaging.py +0 -0
  39. {labdata-0.0.24 → labdata-0.0.25}/labdata/rules/utils.py +0 -0
  40. {labdata-0.0.24 → labdata-0.0.25}/labdata/s3.py +0 -0
  41. {labdata-0.0.24 → labdata-0.0.25}/labdata/schema/__init__.py +0 -0
  42. {labdata-0.0.24 → labdata-0.0.25}/labdata/schema/ephys.py +0 -0
  43. {labdata-0.0.24 → labdata-0.0.25}/labdata/schema/general.py +0 -0
  44. {labdata-0.0.24 → labdata-0.0.25}/labdata/schema/histology.py +0 -0
  45. {labdata-0.0.24 → labdata-0.0.25}/labdata/schema/onephoton.py +0 -0
  46. {labdata-0.0.24 → labdata-0.0.25}/labdata/schema/procedures.py +0 -0
  47. {labdata-0.0.24 → labdata-0.0.25}/labdata/schema/tasks.py +0 -0
  48. {labdata-0.0.24 → labdata-0.0.25}/labdata/schema/twophoton.py +0 -0
  49. {labdata-0.0.24 → labdata-0.0.25}/labdata/schema/utils.py +0 -0
  50. {labdata-0.0.24 → labdata-0.0.25}/labdata/schema/video.py +0 -0
  51. {labdata-0.0.24 → labdata-0.0.25}/labdata/utils.py +0 -0
  52. {labdata-0.0.24 → labdata-0.0.25}/labdata/widgets.py +0 -0
  53. {labdata-0.0.24 → labdata-0.0.25}/labdata.egg-info/dependency_links.txt +0 -0
  54. {labdata-0.0.24 → labdata-0.0.25}/labdata.egg-info/entry_points.txt +0 -0
  55. {labdata-0.0.24 → labdata-0.0.25}/labdata.egg-info/top_level.txt +0 -0
  56. {labdata-0.0.24 → labdata-0.0.25}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labdata
3
- Version: 0.0.24
3
+ Version: 0.0.25
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
@@ -23,7 +23,7 @@ Requires-Dist: dash; extra == "dashboard"
23
23
  Requires-Dist: dash_bootstrap_components; extra == "dashboard"
24
24
  Requires-Dist: dash-extensions; extra == "dashboard"
25
25
  Requires-Dist: plotly; extra == "dashboard"
26
- Requires-Dist: streamlit>1.34; extra == "dashboard"
26
+ Requires-Dist: streamlit>1.54; extra == "dashboard"
27
27
  Requires-Dist: streamlit-bokeh; extra == "dashboard"
28
28
  Requires-Dist: scipy; extra == "dashboard"
29
29
  Requires-Dist: scikit-image; extra == "dashboard"
@@ -1,4 +1,4 @@
1
- VERSION = "0.0.24"
1
+ VERSION = "0.0.25"
2
2
 
3
3
  from .utils import *
4
4
  from .copy import copy_to_upload_server
@@ -44,7 +44,7 @@ def _jobs_panel(table, status_field, target_field, cols, delete_key):
44
44
  x='Jobs:Q',
45
45
  color=alt.Color('color:N').scale(None)
46
46
  ).configure_axis(labelLimit=3000))
47
- st.altair_chart(ch, use_container_width=True)
47
+ st.altair_chart(ch, width="stretch")
48
48
 
49
49
  # filtered table
50
50
  selected_states = st.multiselect('Select status:', STATES, default=STATES[1:], key=f'{delete_key}_filter')
@@ -58,7 +58,7 @@ def _jobs_panel(table, status_field, target_field, cols, delete_key):
58
58
  display_df,
59
59
  column_config={'Select': st.column_config.CheckboxColumn(required=True)},
60
60
  disabled=[c for c in display_df.columns if c != 'Select'],
61
- use_container_width=True,
61
+ width="stretch",
62
62
  key=f'{delete_key}_editor',
63
63
  )
64
64
  selected_jobs = edited[edited['Select'] == True].index.tolist()
@@ -106,11 +106,15 @@ def decisionmaking_tab(schema = None):
106
106
  lambda x: x.strftime("%y-%m-%d"))
107
107
  return summarysource
108
108
 
109
+ if st.button('Refresh', icon=':material/refresh:'):
110
+ get_subjects_last_ran.clear()
111
+ get_summary_data.clear()
112
+
109
113
  all_subjects,subjects,max_date = get_subjects_last_ran()
110
114
  if not len(subjects):
111
115
  st.write('There are no sessions.')
112
116
  return
113
-
117
+
114
118
  selected_indices = st.multiselect('Select subjects:',all_subjects,default = [s['subject_name'] for s in subjects])
115
119
  if len(selected_indices):
116
120
  # cache data to run faster
@@ -124,7 +128,7 @@ def decisionmaking_tab(schema = None):
124
128
  scale=alt.Scale(nice={'interval': 'day', 'step': 1})).axis(labelAngle=45,format='%Y-%m-%d').title('Date'),
125
129
  y=alt.Y('subject_name:O').title('Subject name'),
126
130
  color=alt.Color('performance_easy:Q',
127
- scale=alt.Scale(scheme='spectral', reverse=True,domain = [0.5,1])).title('Perf easy'),
131
+ scale=alt.Scale(scheme='spectral', reverse=True, domain=[0.5,1], clamp=True)).title('Perf easy'),
128
132
  tooltip = ['subject_name','session_name','session_datetime','performance_easy','performance','n_trials']
129
133
 
130
134
  ).properties(width=1000, title ='Behavioral performance').add_params(point_selector)
@@ -160,6 +164,10 @@ def decisionmaking_tab(schema = None):
160
164
  & dict(subject_name=subject_name, session_name=session_name)
161
165
  ).fetch(*cols, as_dict=True)
162
166
 
167
+ if st.button('Refresh sessions', icon=':material/refresh:'):
168
+ get_subject_data.clear()
169
+ get_session_trials.clear()
170
+
163
171
  trialset, weights, watering = get_subject_data(subject_name)
164
172
  sessions = sorted(trialset['session_datetime'].unique())
165
173
 
@@ -192,7 +200,7 @@ def decisionmaking_tab(schema = None):
192
200
 
193
201
  with col_summary:
194
202
  black, orange = '#000000','#ff7f0e'
195
- perf_val = trialset.dropna(subset=['performance_easy','performance']).sort_values('session_datetime').drop_duplicates('session_datetime').iloc[[-20]]
203
+ perf_val = trialset.dropna(subset=['performance_easy','performance']).sort_values('session_datetime').drop_duplicates('session_datetime').iloc[[-1]]
196
204
  x_enc = alt.X('session_datetime:T', axis=axis).title('Session date')
197
205
  perf_chart = (
198
206
  alt.Chart(trialset).mark_line(color=black).encode(x=x_enc, y=alt.Y('performance_easy:Q', axis=axis).title('Performance')).properties(width=1000) +
@@ -224,7 +232,7 @@ def decisionmaking_tab(schema = None):
224
232
  chartw + make_ruler('weighing_datetime'),
225
233
  charth2o + make_ruler('watering_datetime'),
226
234
  ).resolve_scale(x='shared')
227
- st.altair_chart(linked.interactive(), use_container_width=True)
235
+ st.altair_chart(linked.interactive(), width="stretch")
228
236
 
229
237
  with col_psych:
230
238
 
@@ -251,7 +259,7 @@ def decisionmaking_tab(schema = None):
251
259
  lines = base.mark_line().encode(y=alt.Y('proportion:Q'))
252
260
  st.altair_chart((lines + errorbars + pts).properties(
253
261
  width=400, height=500, title=f'{pd.Timestamp(selected_dt).strftime("%Y-%m-%d %H:%M")}'
254
- ).interactive(), use_container_width=True)
262
+ ).interactive(), width="stretch")
255
263
 
256
264
  # reaction time per condition
257
265
  rt_rows = []
@@ -291,9 +299,9 @@ def decisionmaking_tab(schema = None):
291
299
  rt_lines = rt_base.mark_line().encode(y=alt.Y('median_rt:Q'))
292
300
  st.altair_chart((rt_lines + rt_err + rt_pts).properties(
293
301
  width=400, height=300, title='Reaction time per condition'
294
- ).interactive(), use_container_width=True)
302
+ ).interactive(), width="stretch")
295
303
  else:
296
- st.write('No response data available for this session.')
304
+ st.error('No data available for this session.')
297
305
 
298
306
  if len(snames) > 1:
299
307
  st.slider('Session', min_value=0, max_value=len(snames)-1,
@@ -0,0 +1,225 @@
1
+ import streamlit as st
2
+ import pandas as pd
3
+ import numpy as np
4
+ from functools import partial
5
+
6
+ import sys
7
+
8
+ def intro_tab(user_name = None, schema = None):
9
+ @st.cache_data
10
+ def get_subjects():
11
+ if user_name is not None:
12
+ df = pd.DataFrame((schema.Subject() & f'user_name = "{user_name}"').fetch())
13
+ else:
14
+ df = pd.DataFrame(schema.Subject().fetch())
15
+ return df.sort_values("subject_name").reset_index(drop=True)
16
+
17
+ @st.cache_data
18
+ def get_sessions(subject_names):
19
+ dfs = [pd.DataFrame((schema.Session() * schema.Dataset() &
20
+ f'subject_name = "{s}"').fetch()) for s in subject_names]
21
+ dfs = [d for d in dfs if len(d)]
22
+ if not dfs:
23
+ return None
24
+ return pd.concat(dfs).sort_values("session_datetime").reset_index(drop=True)
25
+
26
+ @st.cache_data
27
+ def get_files(dataset_keys_frozen):
28
+ keys = [dict(k) for k in dataset_keys_frozen]
29
+ rows = (schema.Dataset.DataFiles * schema.File & keys).fetch(
30
+ 'subject_name', 'session_name', 'dataset_name',
31
+ 'file_path', 'file_size', 'file_datetime', 'storage', as_dict=True)
32
+ if not rows:
33
+ return pd.DataFrame()
34
+ df = pd.DataFrame(rows)
35
+ df['size'] = df['file_size'].apply(
36
+ lambda b: f'{b/1e9:.2f} GB' if b >= 1e9 else f'{b/1e6:.1f} MB' if b >= 1e6 else f'{b/1e3:.0f} KB')
37
+ return df.drop(columns='file_size')
38
+
39
+ @st.cache_data
40
+ def get_users():
41
+ return list(schema.LabMember.fetch('user_name'))
42
+
43
+ @st.cache_data
44
+ def get_strains():
45
+ return list(schema.Strain().fetch('strain_name'))
46
+
47
+ list_tab, add_tab, edit_tab = st.tabs(['Subjects/Sessions', 'New', 'Update'])
48
+
49
+ with list_tab:
50
+ subjects = get_subjects()
51
+ event_subjects = st.dataframe(
52
+ subjects,
53
+ hide_index=True,
54
+ width="stretch",
55
+ on_select='rerun',
56
+ selection_mode='multi-row',
57
+ key='subjects_table',
58
+ )
59
+ sel_subj_idx = (event_subjects.selection or {}).get('rows', [])
60
+ selected_names = list(subjects.iloc[sel_subj_idx]['subject_name']) if sel_subj_idx else []
61
+ if selected_names:
62
+ st.session_state['subjects_selected'] = selected_names
63
+
64
+ sessions = get_sessions(selected_names) if selected_names else None
65
+ if sessions is None:
66
+ st.write('No subjects selected.')
67
+ else:
68
+ dtype_vals = [s if s is not None else 'None' for s in sessions.dataset_type.values]
69
+ uniqueds = np.unique(dtype_vals)
70
+ metric_cols = st.columns(max(1, len(uniqueds)))
71
+ for col, d in zip(metric_cols, uniqueds):
72
+ col.metric(d, sum(1 for v in dtype_vals if v == d))
73
+
74
+ # color palette for dataset types (light backgrounds, readable text)
75
+ type_palette = ['#d0e8ff', '#ffd6d6', '#d4f5d4', '#fff0cc',
76
+ '#e8d4f5', '#ffd9b3', '#d4f0f5', '#f5d4e8',
77
+ '#e8f5d4', '#f5e8d4']
78
+ dtype_list = [s if s is not None else 'None' for s in sessions['dataset_type'].values]
79
+ unique_types = list(dict.fromkeys(dtype_list)) # preserve order, deduplicate
80
+ type_color = {t: type_palette[i % len(type_palette)] for i, t in enumerate(unique_types)}
81
+
82
+ def _color_rows(row):
83
+ dt = row.get('dataset_type')
84
+ dt = dt if dt is not None else 'None'
85
+ bg = type_color.get(dt, '')
86
+ return [f'background-color: {bg}'] * len(row)
87
+
88
+ sess_styled = sessions.style.apply(_color_rows, axis=1)
89
+ event_sessions = st.dataframe(
90
+ sess_styled,
91
+ hide_index=True,
92
+ width="stretch",
93
+ on_select='rerun',
94
+ selection_mode='multi-row',
95
+ key='sessions_table',
96
+ )
97
+ sel_indices = (event_sessions.selection or {}).get('rows', [])
98
+ sel_datasets = sessions.iloc[sel_indices] if sel_indices else pd.DataFrame()
99
+ if len(sel_datasets):
100
+ dataset_keys = sel_datasets[['subject_name', 'session_name', 'dataset_name']].to_dict('records')
101
+ file_stats = get_files(tuple(frozenset(d.items()) for d in dataset_keys))
102
+ if len(file_stats):
103
+ st.write('### Files')
104
+ ds_order = list(dict.fromkeys(file_stats['dataset_name'].values))
105
+ even_color, odd_color = '#dceeff', '#fff3dc'
106
+ ds_color = {ds: (even_color if i % 2 == 0 else odd_color)
107
+ for i, ds in enumerate(ds_order)}
108
+
109
+ def _color_file_rows(row):
110
+ bg = ds_color.get(row['dataset_name'], '')
111
+ return [f'background-color: {bg}'] * len(row)
112
+
113
+ file_styled = file_stats.style.apply(_color_file_rows, axis=1)
114
+ event_files = st.dataframe(
115
+ file_styled,
116
+ hide_index=True,
117
+ width="stretch",
118
+ on_select='rerun',
119
+ selection_mode='multi-row',
120
+ key='files_table',
121
+ )
122
+ sel_file_idx = (event_files.selection or {}).get('rows', [])
123
+ if sel_file_idx:
124
+ sel_files = file_stats.iloc[sel_file_idx]
125
+ st.caption(f'{len(sel_file_idx)} file(s) selected')
126
+ if st.button('Download files', key='download_files_btn'):
127
+ keys = sel_files[['file_path', 'storage']].to_dict('records')
128
+ with st.spinner(f'Downloading {len(sel_file_idx)} file(s)…'):
129
+ local = (schema.File & keys).get()
130
+ if local:
131
+ st.success(f'Downloaded {len(local)} file(s).')
132
+ for p in local:
133
+ st.code(str(p))
134
+ else:
135
+ st.write('No files found for the selected datasets.')
136
+
137
+ with add_tab:
138
+ users = get_users()
139
+ available_strains = get_strains()
140
+ project_users = list((schema.Project.User() & f'project_name = "{schema.schema_project}"').fetch('user_name'))
141
+ userindex = users.index(project_users[0]) if project_users and project_users[0] in users else None
142
+ with st.form('add_subject'):
143
+ insert_dict = dict()
144
+ insert_dict['user_name'] = st.selectbox('User Name', users, index=userindex)
145
+ insert_dict['subject_name'] = st.text_input('Subject ID', value=None)
146
+ insert_dict['subject_dob'] = st.date_input('Date of Birth')
147
+ sex = st.selectbox('Sex', ['M', 'F', 'Unknown'])
148
+ insert_dict['subject_sex'] = 'U' if sex == 'Unknown' else sex
149
+ insert_dict['strain_name'] = st.selectbox('Strain', available_strains)
150
+ if st.form_submit_button('Add Subject', type='primary'):
151
+ schema.Subject().insert1(insert_dict)
152
+ get_subjects.clear()
153
+ st.success(f'Added subject {insert_dict["subject_name"]}.')
154
+ st.rerun()
155
+
156
+ with edit_tab:
157
+ users = get_users()
158
+ available_strains = get_strains()
159
+ selected_names = st.session_state.get('subjects_selected', [])
160
+ if not selected_names:
161
+ st.info('Select a subject in the Subjects/Sessions tab first.')
162
+ else:
163
+ selected_subject = selected_names[0]
164
+ st.write(f'### Edit subject: {selected_subject}')
165
+ with st.form('edit_subject'):
166
+ edit_dict = (schema.Subject & dict(subject_name=selected_subject)).fetch1()
167
+ edit_dict['subject_dob'] = st.date_input('Date of Birth', value=edit_dict['subject_dob'])
168
+ edit_dict['subject_sex'] = st.selectbox('Sex', ['M', 'F', 'U'],
169
+ index=['M', 'F', 'U'].index(edit_dict['subject_sex']))
170
+ edit_dict['strain_name'] = st.selectbox('Strain', available_strains,
171
+ index=available_strains.index(edit_dict['strain_name']))
172
+ if st.form_submit_button('Save changes', type='primary'):
173
+ schema.Subject().update1(edit_dict)
174
+ get_subjects.clear()
175
+ st.success(f'Updated {selected_subject}.')
176
+ st.rerun()
177
+ st.set_page_config(
178
+ page_title="labdata dashboard",
179
+ page_icon="🧪",
180
+ layout="wide",
181
+ initial_sidebar_state="auto")
182
+
183
+
184
+ from procedures import procedures_tab
185
+ from notes import notes_tab
186
+ from sorting import sorting_tab
187
+ from segmentation import segmentation_tab
188
+ from video import video_tab
189
+ from decisiontask import decisionmaking_tab
190
+ from compute import compute_tab
191
+
192
+ @st.cache_resource
193
+ def load_schema(project):
194
+ # load project
195
+ from labdata import load_project_schema
196
+ return load_project_schema(project)
197
+
198
+ project = None
199
+ for k in sys.argv:
200
+ if 'project=' in k:
201
+ project = k.replace('project=','')
202
+ with st.spinner(f'Connecting to {"project " + project if project else "database"}…'):
203
+ schema = load_schema(project)
204
+
205
+ page_names_to_funcs = {
206
+ "**Subjects**": partial(intro_tab, schema = schema),
207
+ "**Procedures**": partial(procedures_tab, schema = schema),
208
+ "**Notes**": partial(notes_tab, schema = schema),
209
+ "**Spike sorting**": partial(sorting_tab, schema = schema),
210
+ "**Cell Segmentation**": partial(segmentation_tab, schema = schema),
211
+ "**Video**": partial(video_tab, schema = schema),
212
+ "**Decision-making behavior**": partial(decisionmaking_tab, schema = schema),
213
+ "**Compute/Upload**": partial(compute_tab, schema = schema),
214
+ }
215
+
216
+ from labdata import plugins
217
+ for p in plugins.keys():
218
+ if hasattr(plugins[p],'dashboard_function'):
219
+ page_names_to_funcs[plugins[p].dashboard_name] = partial(plugins[p].dashboard_function, schema = schema)
220
+
221
+ tab_name = st.sidebar.radio(
222
+ "``labdata dashboard``",
223
+ page_names_to_funcs.keys())
224
+
225
+ page_names_to_funcs[tab_name]()
@@ -0,0 +1,184 @@
1
+ import pandas as pd
2
+ import streamlit as st
3
+ from datetime import datetime, date, time as dtime
4
+
5
+
6
+ def notes_tab(schema=None):
7
+
8
+ @st.cache_data
9
+ def get_users():
10
+ return list(schema.LabMember.fetch('user_name'))
11
+
12
+ @st.cache_data
13
+ def get_subjects():
14
+ return list(schema.Subject.fetch('subject_name'))
15
+
16
+ @st.cache_data
17
+ def get_sessions(subject_name):
18
+ return list((schema.Session & dict(subject_name=subject_name)).fetch('session_name'))
19
+
20
+ @st.cache_data
21
+ def get_datasets(subject_name, session_name):
22
+ return list((schema.Dataset & dict(subject_name=subject_name,
23
+ session_name=session_name)).fetch('dataset_name'))
24
+
25
+ @st.cache_data
26
+ def get_note_counts():
27
+ rows = (schema.Subject.aggr(
28
+ schema.Dataset * schema.Note,
29
+ note_count='count(*)'
30
+ )).fetch(as_dict=True)
31
+ df = pd.DataFrame(rows)
32
+ if len(df):
33
+ return df[df['note_count'] > 0].sort_values('note_count', ascending=False).reset_index(drop=True)
34
+ return []
35
+
36
+ @st.cache_data
37
+ def get_subject_notes(subject_name):
38
+ query = schema.Dataset * schema.Session * schema.Note & dict(subject_name=subject_name)
39
+ rows = query.fetch(as_dict=True)
40
+ if not rows:
41
+ return pd.DataFrame()
42
+ df = pd.DataFrame(rows)
43
+ cols = ['subject_name', 'session_name', 'dataset_name', 'session_datetime',
44
+ 'notes', 'notetaker', 'note_datetime']
45
+ cols = [c for c in cols if c in df.columns]
46
+ return df[cols].sort_values('note_datetime', ascending=False).reset_index(drop=True)
47
+
48
+ users = get_users()
49
+ subjects = get_subjects()
50
+
51
+ browse_tab, update_tab, add_tab = st.tabs(['Browse', 'Update', 'Create'])
52
+
53
+ def _render_notes_table(notes_df, editor_key):
54
+ display_df = notes_df.copy()
55
+ display_df.insert(0, 'Select', False)
56
+ edited = st.data_editor(
57
+ display_df,
58
+ column_config={'Select': st.column_config.CheckboxColumn(required=True)},
59
+ disabled=[c for c in display_df.columns if c != 'Select'],
60
+ width="stretch",
61
+ key=editor_key,
62
+ )
63
+ selected_rows = edited[edited['Select'] == True]
64
+ if len(selected_rows) > 1:
65
+ st.warning('Multiple notes selected — selecting the first one.')
66
+ if len(selected_rows):
67
+ st.session_state['selected_note'] = selected_rows.iloc[0].to_dict()
68
+
69
+ with browse_tab:
70
+ import altair as alt
71
+ counts_df = get_note_counts()
72
+ subject_filter = None
73
+ if len(counts_df):
74
+ sel = alt.selection_point(fields=['subject_name'], name='subject_sel')
75
+ chart = (alt.Chart(counts_df).mark_bar().encode(
76
+ x=alt.X('subject_name:N', sort='ascending', title='Subject'),
77
+ y=alt.Y('note_count:Q', title='# notes'),
78
+ color=alt.condition(sel, alt.value('black'), alt.value('#aaaaaa')),
79
+ tooltip=['subject_name:N', 'note_count:Q'],
80
+ ).add_params(sel).properties(height=200))
81
+ event = st.altair_chart(chart, width="stretch", on_select='rerun',
82
+ key='notes_count_chart')
83
+ if st.session_state.pop('notes_clear_pending', False):
84
+ st.session_state.pop('notes_selected_subject', None)
85
+ else:
86
+ pts = (event.selection or {}).get('subject_sel', [])
87
+ if pts and pts[0].get('subject_name'):
88
+ st.session_state['notes_selected_subject'] = pts[0]['subject_name']
89
+ subject_filter = st.session_state.get('notes_selected_subject')
90
+ if subject_filter:
91
+ st.write(f'**Selected:** {subject_filter}')
92
+ if st.button('Clear selection', key='notes_clear_subject'):
93
+ st.session_state['notes_clear_pending'] = True
94
+ st.rerun()
95
+ else:
96
+ st.write('No notes in the database.')
97
+
98
+ if subject_filter:
99
+ notes_df = get_subject_notes(subject_filter)
100
+ if len(notes_df):
101
+ if len(notes_df) > 1:
102
+ idx = st.slider('Note', min_value=1, max_value=len(notes_df), value=1,
103
+ format='%d', key='notes_slider',
104
+ help='Cycle through notes (sorted by date, newest first)')
105
+ row = notes_df.iloc[idx - 1]
106
+ st.write(f"**{row.get('session_datetime', '')}** — {row.get('dataset_name', '')} — *{row.get('notetaker', '')}*")
107
+ st.write(row.get('notes', ''))
108
+ st.session_state['selected_note'] = row.to_dict()
109
+ with st.expander('Show all notes as table'):
110
+ _render_notes_table(notes_df, 'notes_browse_editor')
111
+ else:
112
+ _render_notes_table(notes_df, 'notes_browse_editor')
113
+ else:
114
+ st.write('No notes found for this subject.')
115
+
116
+ with add_tab:
117
+ st.write('### Add a note to a dataset')
118
+ subject_name = st.selectbox('Subject', subjects, index=None, key='notes_add_subject')
119
+ session_name = None
120
+ dataset_name = None
121
+ if subject_name:
122
+ sessions = get_sessions(subject_name)
123
+ session_name = st.selectbox('Session', sessions, index=None, key='notes_add_session')
124
+ if session_name:
125
+ datasets = get_datasets(subject_name, session_name)
126
+ dataset_name = st.selectbox('Dataset', datasets, index=None, key='notes_add_dataset')
127
+ with st.form('add_note'):
128
+ project_users = (schema.Project.User() & f'project_name = "{schema.schema_project}"').fetch('user_name')
129
+ user_index = None
130
+ if len(project_users) and project_users[0] in users:
131
+ user_index = users.index(project_users[0])
132
+ notetaker = st.selectbox('Notetaker', users, index=user_index)
133
+ note_date = st.date_input('Date', value=date.today())
134
+ note_time = st.time_input('Time', value=dtime(datetime.now().hour, datetime.now().minute))
135
+ note_text = st.text_area('Note (max 4000 characters)', max_chars=4000)
136
+ submitted = st.form_submit_button('Add Note', type='primary')
137
+ if submitted:
138
+ if not subject_name:
139
+ st.error('Select a subject.')
140
+ elif not session_name:
141
+ st.error('Select a session.')
142
+ elif not dataset_name:
143
+ st.error('Select a dataset.')
144
+ elif not notetaker:
145
+ st.error('Select a notetaker.')
146
+ elif not note_text.strip():
147
+ st.error('Note text cannot be empty.')
148
+ else:
149
+ note_dt = datetime.combine(note_date, note_time)
150
+ dataset_key = dict(subject_name=subject_name, session_name=session_name,
151
+ dataset_name=dataset_name)
152
+ schema.Note.insert1(dict(notetaker=notetaker, note_datetime=note_dt, notes=note_text))
153
+ schema.Dataset.update1({**dataset_key, 'notetaker': notetaker, 'note_datetime': note_dt})
154
+ get_note_counts.clear()
155
+ get_subject_notes.clear()
156
+ st.success(f'Note {dataset_key} added successfully.')
157
+
158
+ with update_tab:
159
+ selected = st.session_state.get('selected_note', {})
160
+ if not selected:
161
+ st.info('Select a note in the Browse tab first.')
162
+ else:
163
+ notetaker_upd = selected.get('notetaker')
164
+ note_dt_upd = selected.get('note_datetime')
165
+ pre_text = selected.get('notes', '')
166
+ subject_name_upd = selected.get('subject_name')
167
+ session_name_upd = selected.get('session_name')
168
+ dataset_name_upd = selected.get('dataset_name')
169
+ if subject_name_upd and session_name_upd and dataset_name_upd:
170
+ st.write(f"**Subject:** {subject_name_upd} &nbsp; **Session:** {session_name_upd} &nbsp; **Dataset:** {dataset_name_upd}")
171
+ st.write(f"**Notetaker:** {notetaker_upd} &nbsp; **Date/time:** {note_dt_upd}")
172
+ with st.form('update_note'):
173
+ new_text = st.text_area('Note', value=pre_text, max_chars=4000)
174
+ submitted_upd = st.form_submit_button('Update Note', type='primary')
175
+ if submitted_upd:
176
+ if not new_text.strip():
177
+ st.error('Note text cannot be empty.')
178
+ else:
179
+ schema.Note.update1(dict(notetaker=notetaker_upd, note_datetime=note_dt_upd,
180
+ notes=new_text))
181
+ get_note_counts.clear()
182
+ get_subject_notes.clear()
183
+ st.session_state.pop('selected_note', None)
184
+ st.success(f'Note {dict(notetaker=notetaker_upd, note_datetime=note_dt_upd)} updated.')